diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6cedba8 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,236 @@ +# WPS365 CLI Architecture + +## Overview + +WPS365 CLI is a Go-based command-line tool that provides structured access to the WPS 365 Open Platform APIs. It follows a **spec-driven architecture** — the OpenAPI specification files define the API surface, and curated YAML files define the human-friendly command mapping. The Go binary reads these specs at runtime to build its command tree, perform request validation, and manage authentication. + +``` +┌─────────────────────────────────────────────────────────┐ +│ User / Script │ +└──────────────────────┬──────────────────────────────────┘ + │ + ┌────────────┴────────────┐ + │ CLI Flag Parsing │ cobra + pflag + │ (cobra command tree) │ + └────────────┬────────────┘ + │ + ┌───────────────┴───────────────┐ + │ Command Router │ + │ ┌──────────┐ ┌──────────┐ │ + │ │ Curated │ │ Raw API │ │ + │ │ Commands │ │ (api get │ │ + │ │ │ │ /path) │ │ + │ └─────┬─────┘ └────┬─────┘ │ + └─────────┼────────────┼─────────┘ + │ │ + ┌──────┴────────────┴──────┐ + │ Request Builder │ + │ - Resolve path params │ + │ - Bind flags → body/que │ + │ - Apply transforms │ + │ - Validate via schema │ + └──────────┬───────────────┘ + │ + ┌──────────┴───────────────┐ + │ Auth Middleware │ + │ - Select token type │ + │ - Auto-refresh (10s) │ + │ - 401 → retry once │ + └──────────┬───────────────┘ + │ + ┌──────────┴───────────────┐ + │ HTTP Client │ + │ - Send request │ + │ - Dry-run support │ + └──────────┬───────────────┘ + │ + ┌──────────┴───────────────┐ + │ Output Formatter │ + │ json | yaml | table | tsv│ + └──────────────────────────┘ +``` + +## Directory Layout + +Since the Go source code is not published in this repository, the layout shown here reflects the **runtime data directory** and the **published repo contents**. + +### Repository (this repo) + +``` +. +├── README.md # Chinese documentation +├── README.en.md # English documentation +├── install.sh # macOS/Linux installer +├── install.ps1 # Windows PowerShell installer +└── docs/ + ├── prerequisites.md # OAuth app setup guide + └── assets/ # Screenshot images for docs +``` + +### Runtime data directory + +macOS: `~/Library/Application Support/wps365-cli/` +Linux: `~/.local/share/wps365-cli/` (or `$XDG_DATA_HOME/wps365-cli/`) +Windows: `%APPDATA%\wps365-cli\` + +``` +wps365-cli/ +├── spec/ +│ ├── api/ +│ │ ├── 365.yaml # Official OpenAPI 3.0 spec (~74k lines, 801 endpoints) +│ │ └── customs/ # User-provided custom API specs +│ └── curated/ +│ ├── 365.yaml # Curated command definitions (152 commands) +│ └── customs/ # User-provided custom curated specs +└── config.json # Non-secret configuration (client_id, redirect_uri, etc.) +``` + +**Credential storage** (never in the data directory): +- **macOS**: Keychain (via `keyring` Go package) +- **Windows**: Credential Manager +- **Linux**: Encrypted file (`AES-256-GCM`), key auto-generated and stored separately + +## Dual-Track Command System + +### Track 1: Curated Commands + +Curated commands map complex API interactions to semantic CLI verbs. They are defined in `curated/365.yaml`. + +Example definition: + +```yaml +- id: calendar.events.create + command: calendar events create + summary: 创建日程 + method: POST + path: /v7/calendars/{calendar_id}/events/create + args: + - name: calendar-id + required: true + to: path.calendar_id + flags: + - name: from + type: string + required: true + to: body.start_time + - name: name + type: string + to: body.summary + body: + bindings: + - from_flag: from + to: start_time + transform: passthrough +``` + +Key design principles: +- **Semantic naming**: `calendar events create` over `api post /v7/calendars/{id}/events/create` +- **Smart defaults**: Pagination defaults, required-scope auto-validation +- **Type transforms**: `split_csv`, `to_bool`, `to_int` convert CLI strings to API types +- **Auth constraint checking**: Each command declares its security requirement; incompatible token types produce errors + +### Track 2: Raw API Commands + +`api get|post|put|patch|delete|head ` accesses any endpoint in the OpenAPI spec directly. + +```bash +wps365-cli api get "/v7/users/current" +wps365-cli api post "/v7/calendars/create" --data '{"summary": "New Calendar"}' +``` + +This ensures **100% API coverage** even before a curated command exists. + +## Spec Discovery & Loading + +1. On startup, the CLI reads the spec directory path (default or `WPS365_CONFIG_DIR`) +2. It loads `api/365.yaml` to build the full API map +3. It loads `curated/365.yaml` to register curated commands +4. Custom specs in `customs/` are merged, allowing users to extend both raw API and curated commands +5. `spec update` fetches the latest official specs from the remote repository +6. `spec add --custom-api` / `--custom-curated` installs user-provided spec files + +## Authentication Architecture + +``` +┌────────────────────────────────────────────────┐ +│ Auth Provider │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ Delegated │ │ App │ │ +│ │ (User OAuth) │ │ (Client │ │ +│ │ │ │ Credentials)│ │ +│ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ +│ ┌──────┴──────────────────┴───────┐ │ +│ │ Token Store │ │ +│ │ Keychain / Credential Manager │ │ +│ │ / Encrypted File │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Auto-refresh: │ +│ - 10s before expiry → proactive refresh │ +│ - 401 response → transparent retry │ +│ - Delegated: use refresh_token │ +│ - App: re-acquire via client_credentials │ +└────────────────────────────────────────────────┘ +``` + +Three auth modes: +1. **Delegated**: User authorizes via browser OAuth, token includes `refresh_token` +2. **App**: Client credentials grant, no user context, `client_id` + `client_secret` +3. **OSH**: Enterprise gateway credentials for internal environments + +The CLI reads the OpenAPI `security` field for each endpoint to determine which token type is required. If the current token is incompatible, it errors rather than silently switching. + +## Output Pipeline + +All commands route through a shared output pipeline: + +1. API response (JSON) → +2. JMESPath/jsonpath filtering (if `--query` supported) → +3. Format transformer: `json` (default), `yaml`, `table`, `tsv` + +## Global Flags + +| Flag | Purpose | +|------|---------| +| `--api-base` | Override API base URL | +| `--dry-run` | Print request without sending | +| `-o, --output` | Output format: json/yaml/table/tsv | +| `--quiet` | Suppress stderr informational output | + +## Environment Variables + +See [README.md](README.md) for the full list. Key categories: +- **Credentials**: `WPS365_CLIENT_ID`, `WPS365_CLIENT_SECRET`, `WPS365_ACCESS_TOKEN` +- **Endpoints**: `WPS365_API_BASE`, `WPS365_AUTH_URL`, `WPS365_TOKEN_URL` +- **Config**: `WPS365_CONFIG_DIR`, `WPS365_AUTH` (default mode), `WPS365_OUTPUT` +- **Security**: `WPS365_KEYRING_BACKEND`, `WPS365_KEYRING_PASSWORD` + +## Extension Points + +### Custom Spec Files + +Users can extend the CLI without code changes: + +```bash +# Add a custom API endpoint definition +wps365-cli spec add --custom-api ./my-api.yaml + +# Add a custom curated command +wps365-cli spec add --custom-curated ./my-commands.yaml +``` + +This is the primary extension mechanism for organizations with private or internal APIs that follow the WPS 365 Open Platform conventions. + +### CI/CD Integration + +Non-interactive auth via environment variables: + +```bash +export WPS365_CLIENT_ID="" +export WPS365_CLIENT_SECRET="" +wps365-cli auth login --app +``` + +Combined with `--dry-run` for safe scripting and `-o tsv` for piping to other tools. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..76ab49e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 WPS 365 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/design-docs/auth.md b/docs/design-docs/auth.md new file mode 100644 index 0000000..23b041e --- /dev/null +++ b/docs/design-docs/auth.md @@ -0,0 +1,133 @@ +# 认证与凭证设计 + +本文档描述 wps365-cli 的认证架构、凭证存储策略和 token 生命周期管理。 + +## 认证模式 + +wps365-cli 支持三种认证模式,命令根据底层 OpenAPI 的 `security` 声明自动选择,`--token-type` 可显式覆盖。模式不兼容时直接报错,不静默切换。 + +| 模式 | 授权类型 | 适用场景 | 获取方式 | +|------|---------|---------|---------| +| `delegated` | OAuth Authorization Code(浏览器授权) | 当前用户信息、个人日历、个人邮件等用户态接口 | `auth login --scopes "kso.user_base.read,kso.calendar.read"` | +| `app` | Client Credentials | 服务端调用、组织级管理、应用态接口 | `auth login --app` | +| `osh` | OSH 网关 Token | 通过 OSH 网关访问开放能力 | `auth login --osh` | + +### Delegated 模式流程 + +1. CLI 启动本地 HTTP 服务监听 `localhost:18365/callback` +2. 打开浏览器访问 WPS 365 OAuth 授权页面,用户登录并授权 +3. 授权完成后浏览器重定向到回调地址,携带 `code` +4. CLI 使用 `code` + `client_secret` 换取 `access_token` 和 `refresh_token` +5. Token 安全存储到后端,后续请求自动携带 + +### App 模式流程 + +1. CLI 使用 `client_id` + `client_secret` 请求 `https://openapi.wps.cn/oauth2/token` +2. 返回 `access_token`(无 `refresh_token`,过期后重新获取) +3. 适用于 CI/CD 和服务端场景,支持非交互式 + +### OSH 模式流程 + +1. CLI 使用 `client_id` + `client_secret` 请求 `https://open.wps.cn/osh/api/v1/consumers/token` +2. 返回 OSH 网关 access token,用于访问 OSH 开放能力 +3. 与 app 模式类似,无 `refresh_token`,过期后重新获取 + +### 非交互式(CI/CD) + +```bash +export WPS365_CLIENT_ID="" +export WPS365_CLIENT_SECRET="" +wps365-cli auth login --app +``` + +通过环境变量注入凭证,跳过交互式 `auth setup`。 + +### 自定义回调地址 + +Delegated 模式默认回调地址为 `http://localhost:18365/callback`。如果需要自定义(如远程开发环境端口转发),可通过 `--redirect-uri` 覆盖: + +```bash +wps365-cli auth login --scopes "kso.user_base.read" --redirect-uri "http://myhost:18365/callback" +``` + +> 自定义回调地址必须已在 WPS 365 开放平台「安全设置」中注册。 + +## 凭证存储 + +`client_secret` 和 token 永不明文落盘,存储后端按平台选择: + +| 平台 | 默认后端 | 备选 | +|------|---------|------| +| macOS | System Keychain | 加密文件 | +| Windows | Credential Manager | 加密文件 | +| Linux | AES-256-GCM 加密文件 | — | + +通过 `WPS365_KEYRING_BACKEND` 环境变量可强制切换为 `keychain` 或 `file`。 + +### 文件后端加密 + +- 算法:AES-256-GCM +- 密钥来源:`WPS365_KEYRING_PASSWORD` 环境变量,未设置时自动生成随机密钥并持久化到本地 +- 无需额外配置即可使用 + +### 直接注入 Token + +`WPS365_ACCESS_TOKEN` 环境变量可直接注入 access token,跳过存储和刷新逻辑。适用于已有 token 的外部集成场景。 + +`WPS365_OSH_TOKEN` 环境变量可直接注入 OSH 网关 token,效果类似。 + +## Token 生命周期 + +### 自动刷新策略 + +| 事件 | Delegated 模式 | App 模式 | OSH 模式 | +|------|-----------|-----|------| +| 过期前 10 秒 | 使用 `refresh_token` 刷新 | 使用 `client_credentials` 重新获取 | 使用 `client_credentials` 重新获取 | +| 收到 401 响应 | 透明刷新 + 重试 | 透明重新获取 + 重试 | 透明重新获取 + 重试 | +| Refresh token 过期 | 提示重新执行 `auth login` | 不适用 | 不适用 | + +### 手动操作 + +```bash +wps365-cli auth status # 查看当前 token 状态、过期时间、认证模式 +wps365-cli auth token # 输出 access token(供 curl 等外部工具使用) +wps365-cli auth refresh --delegated # 手动刷新 delegated token +wps365-cli auth refresh --app # 手动刷新 app token +wps365-cli auth logout # 删除 token(保留凭证,可直接重新 login) +wps365-cli auth clean # 清除所有 token、凭证和自动密钥(完全重置,交互确认) +``` + +## 命令与认证模式映射 + +每条命令根据底层 OpenAPI 的 `security` 声明确定所需认证模式: + +- `security: [{ oauth2: [user_scope] }]` → delegated +- `security: [{ oauth2: [app_scope] }]` → app +- 同时声明两者时,优先 delegated,`--token-type` 可覆盖 + +不匹配时 CLI 直接报错,不静默切换模式。这确保用户明确知道当前操作使用的身份。 + +## 常用 Scope 参考 + +| 业务域 | 常用 Scope | 说明 | +|--------|-----------|------| +| 用户 | `kso.user_base.read` | 读取当前用户基本信息 | +| 日历 | `kso.calendar.read` | 读取日历和日程 | +| 日历 | `kso.calendar.write` | 创建/修改/删除日程 | +| 即时通讯 | `kso.chat.message.read` | 读取消息 | +| 即时通讯 | `kso.chat.message.write` | 发送消息 | +| 通讯录 | `kso.contact.user.read` | 读取通讯录用户信息 | +| 云文档 | `kso.drive.file.read` | 读取云文档 | +| 云文档 | `kso.drive.file.write` | 上传/修改云文档 | +| 多维表 | `kso.dbsheet.read` | 读取多维表 | +| 邮箱 | `kso.mail.read` | 读取邮件 | + +> 完整 Scope 列表和权限申请方式请参考 [前置准备](../prerequisites.md) 和 WPS 365 开放平台开发者后台。 + +## 安全考量 + +- OAuth 回调仅监听 `localhost`,不暴露到公网 +- `client_secret` 不出现在命令行参数或日志中 +- Token 刷新请求中的 `client_secret` 仅在内存中使用 +- 401 重试最多一次,避免无限循环 +- `auth clean` 同时删除自动生成的加密密钥,确保彻底清除 diff --git a/docs/design-docs/curated-commands.md b/docs/design-docs/curated-commands.md new file mode 100644 index 0000000..97250c4 --- /dev/null +++ b/docs/design-docs/curated-commands.md @@ -0,0 +1,268 @@ +# Curated Command Design Principles + +This document describes the design principles behind WPS365 CLI's curated commands — the hand-crafted, high-level CLI verbs that sit atop the raw OpenAPI surface. + +## Why Curated Commands? + +The WPS 365 Open Platform exposes 800+ endpoints. Calling them directly via `api get|post` works, but raw API paths are verbose, require understanding of request body schemas, and offer no guardrails for auth or required fields. + +Curated commands solve three problems: + +1. **Discoverability** — `wps365-cli calendar events create` is self-documenting; `api post "/v7/calendars/{calendar_id}/events/create" --data '{...}'` is not. +2. **Safety** — Required flags surface immediately. Auth constraints (`delegated` vs `app`) are validated before the request is sent. Incompatible overrides produce errors, not silent 403s. +3. **Ergonomics** — Semantic flags with smart defaults and transforms replace manual JSON body construction. + +## Dual-Track Architecture + +``` +User input + │ + ├─ Curated command? ──► Spec-driven resolution ──► HTTP request + │ (e.g. calendar events create) + │ + └─ API command? ──────► Direct path + body ──────► HTTP request + (e.g. api post "/v7/...") +``` + +Curated commands are not a separate API layer — they resolve to the same HTTP calls. The `api` track exists as an escape hatch for endpoints not yet curated. + +## Curated Spec Schema + +Each curated command is defined in a YAML spec (`spec/curated/365.yaml`) with this structure: + +```yaml +version: 1 +commands: + - id: calendar.events.create # Unique dot-separated identifier + command: calendar events create # CLI invocation path + summary: 创建日程 # One-line description + description: ... # Extended help text + method: POST # HTTP method + path: /v7/calendars/{calendar_id}/events/create + request_schema_ref: "#/components/schemas/..." # Optional: OpenAPI request body schema ref + response_schema_ref: "#/components/schemas/..." # OpenAPI response schema ref + args: # Positional arguments + flags: # Named flags + headers: # Dynamic headers + body: # Request body mapping + defaults: # Implicit body values + bindings: # Flag → body field mappings + examples: # Usage examples for --help +``` + +### Key Design Decisions + +**1. `id` mirrors `command` with dots** + +`calendar.events.create` → `wps365-cli calendar events create`. This makes the spec searchable and the relationship between spec entry and CLI path trivial. + +**2. `method` is explicit, not inferred** + +Even though most mutations are POST, we record the actual HTTP verb. This prevents ambiguity for edge cases like `user.batch-get` (POST for bulk read). + +**3. `path` uses OpenAPI parameter syntax** + +Path templates like `{calendar_id}` match the OpenAPI spec. The runtime resolves them from `args` with `to: path.calendar_id`. + +## Argument vs Flag Design + +### Positional Arguments (`args`) + +Use for **resource identifiers** — the primary object the command operates on: + +```yaml +args: + - name: calendar-id + required: true + description: 日历 id,可使用 primary 指代主日历 + to: path.calendar_id +``` + +Design rules: + +- Limit to 1–2 positional args (the resource and sometimes a sub-resource). +- IDs are positional; everything else is a flag. +- Use kebab-case for arg names; the CLI auto-translates to the path parameter. + +### Named Flags (`flags`) + +Use for **all other parameters** — query params, body fields, and metadata: + +```yaml +flags: + - name: page-size + type: integer + default: 20 + description: 每页返回的日历数量 + to: query.page_size + - name: name + type: string + description: 日程标题 +``` + +Design rules: + +- Use `to:` to explicitly map to the target location (`query.`, `path.`, or body field). +- Provide `default:` for commonly accepted values (page sizes, receiver types). +- Mark `required: true` for flags that have no sensible default. + +## Body Mapping System + +The body mapping system translates flat CLI flags into nested JSON request bodies. + +### Defaults + +Implicit body values that don't come from user flags: + +```yaml +body: + defaults: + type: text + receivers[0].type: user + content.text.type: plain +``` + +These are injected before flag bindings, providing baseline structure. + +### Bindings + +Flag-to-field mappings with optional transforms: + +```yaml +body: + bindings: + - from_flag: to + to: receivers[0].receiver_ids + - from_flag: text + to: content.text.content + - from_flag: reminders + to: reminders + transform: "split_csv | to_int | wrap(minutes)" + - from_flag: recurrence-body + to: recurrence + transform: parse_json +``` + +### Transform Pipeline + +Transforms modify flag values before injection: + +| Transform | Input | Output | Usage | +|-----------|-------|--------|-------| +| `split_csv` | `"30,10"` | `["30","10"]` | Comma-separated → array | +| `to_int` | `["30","10"]` | `[30,10]` | String → integer | +| `to_bool` | `"true"` | `true` | String → boolean | +| `negate` | `true` | `false` | Boolean flip | +| `wrap(key=value)` | `[30,10]` | `[{"minutes":30},{"minutes":10}]` | Array of objects | +| `parse_json` | `'{"RRULE":"FREQ=WEEKLY"}'` | `{"RRULE":"FREQ=WEEKLY"}` | JSON string → object | + +Transforms compose with `|` (pipe), evaluated left to right. + +### Design Rules for Body Mapping + +1. **Flat flags, nested bodies** — Never require users to pass raw JSON for common fields. +2. **`parse_json` escape hatch** — Complex sub-objects (recurrence rules, online meeting config) accept a JSON string via `parse_json`. +3. **Wrap for array-of-objects** — When the API expects `[{minutes:30}]`, use `split_csv | to_int | wrap(minutes)` so the user just types `--reminders "30,10"`. + +## Header Binding + +Some APIs require custom headers (e.g., `X-Kso-Id-Type`). Curated commands expose these as flags and bind them: + +```yaml +flags: + - name: id-type + type: string + default: internal + description: 对应请求头 X-Kso-Id-Type +headers: + - name: X-Kso-Id-Type + from_flag: id-type +``` + +## Auth Constraint Validation + +Each curated command inherits `security` requirements from the OpenAPI spec. The runtime checks whether the current auth mode (delegated/app) is compatible before sending the request. If not, it errors immediately: + +``` +Error: this endpoint requires delegated auth; current session is app-only. +Run: wps365-cli auth login --scopes "..." +``` + +Users can override with `--token-type`, but incompatible overrides still error rather than silently failing. + +## Naming Conventions + +### Resource Names + +| API Concept | CLI Resource | Example | +|-------------|-------------|---------| +| Calendar | `calendar` | `calendar list` | +| Calendar Event | `calendar events` | `calendar events create` | +| Event Attendee | `calendar event-attendee` | `calendar event-attendee add` | +| Meeting Room | `meeting rooms` | `meeting rooms search` | +| Meeting Recording | `meeting recordings` | `meeting recordings start` | + +Rules: + +- **Plural sub-resources**: `calendar events`, `drive files`, `dbsheet records`. +- **Hyphenated compounds**: `event-attendee`, `free-busy`, `room-level`. +- **Consistent verbs**: `list`, `get`, `create`, `update`, `delete` for CRUD; `add`/`remove` for collection mutations; `search` for full-text; `batch-*` for bulk operations. + +### Verb Selection Guide + +| Intent | Verb | When to Use | +|--------|------|-------------| +| List collection | `list` | Paginated read of a collection | +| Get single item | `get` | Read by ID | +| Create new item | `create` | POST that creates a resource | +| Modify existing | `update` | Partial/full modification | +| Remove existing | `delete` | Destructive removal | +| Add to collection | `add` | Append members to a group (e.g., attendees to event) | +| Remove from collection | `remove` | Remove members from a group | +| Full-text search | `search` | Query by keyword | +| Bulk create | `batch-create` | Multiple items in one call | +| Bulk read | `batch-get` | Multiple reads by IDs | +| Bulk delete | `batch-delete` | Multiple deletions | +| State transition | Action verb (`end`, `respond`, `recall`) | Non-CRUD operations | + +## Curated Command Coverage + +Current stats (v0.1.0): + +| Domain | Curated Commands | API Paths | +|--------|-----------------|-----------| +| Calendar | 28 | ~35 | +| IM | 20 | ~25 | +| User | 9 | ~10 | +| Mail | 11 | ~12 | +| Drive | 29 | ~40 | +| DbSheet | 22 | ~30 | +| Meeting | 33 | ~45 | +| **Total** | **152** | **~197** | + +The remaining ~600 API paths are accessible via `api get|post`. Curation is an ongoing process — high-traffic endpoints are prioritized. + +## Comparison with Slock CLI (kscc) + +| Dimension | wps365-cli | kscc (Slock) | +|-----------|------------|-------------| +| Spec source | OpenAPI YAML + curated YAML | Internal TypeScript definitions | +| Command count | 152 curated | ~20 commands | +| Auth modes | delegated + app (OAuth2) | agent token (device-code flow) | +| Body mapping | Declarative YAML bindings | Code-based argument parsing | +| Agent integration | `--dry-run` + `-o json` | Native MCP protocol + hook system | +| Extensibility | Custom spec files via `spec add` | Plugin manifest + integration login | + +Key insight: kscc's MCP-first design is more agent-native, while wps365-cli's spec-driven approach is more scalable for large API surfaces. A future MCP adapter layer for wps365-cli (see [openapi-cli-mapping.md](openapi-cli-mapping.md)) could bridge this gap. + +## Contributing New Curated Commands + +To add a new curated command: + +1. Identify the target API endpoint in `spec/api/365.yaml`. +2. Add an entry to `spec/curated/365.yaml` following the schema above. +3. Test with `wps365-cli spec set --curated `. +4. Verify with `--dry-run` that the request is correct. +5. Submit a PR with both the spec change and example output. + +See [openapi-cli-mapping.md](openapi-cli-mapping.md) for the mapping rules between OpenAPI paths and CLI commands. diff --git a/docs/design-docs/openapi-cli-mapping.md b/docs/design-docs/openapi-cli-mapping.md new file mode 100644 index 0000000..e6f3d2d --- /dev/null +++ b/docs/design-docs/openapi-cli-mapping.md @@ -0,0 +1,257 @@ +# OpenAPI-to-CLI Command Mapping Rules + +This document defines how WPS 365 OpenAPI paths map to CLI commands, both for curated commands and the raw `api` escape hatch. + +## Mapping Overview + +``` +OpenAPI Path CLI Command +───────────────── ────────────────────────────── +/v7/calendars → calendar list +/v7/calendars/{id}/events/create → calendar events create +/v7/messages/batch_create → im messages send --to ... --text ... +/v7/coop/dbsheet/{fid}/schema → dbsheet schema get +/v7/aiopen/aigc/compose → api post "/v7/aiopen/aigc/compose" +``` + +The mapping is defined declaratively in two spec files: +- **API spec** (`spec/api/365.yaml`): Full OpenAPI 3.0 definition with paths, schemas, and security. +- **Curated spec** (`spec/curated/365.yaml`): Hand-crafted command definitions that reference the API spec. + +## Path-to-Command Transformation Rules + +### Rule 1: Resource Extraction + +Strip `/v7/` prefix and API version, then split the remaining path into resource segments: + +``` +/v7/calendars/{calendar_id}/events/create + ────────────────────── ────── ────── + resource path sub verb +``` + +### Rule 2: Path Parameters → Positional Args + +OpenAPI path parameters `{calendar_id}`, `{event_id}` become positional CLI args: + +| OpenAPI Path Parameter | CLI Arg Name | Position | +|------------------------|-------------|----------| +| `{calendar_id}` | `calendar-id` | 1st | +| `{event_id}` | `event-id` | 2nd | +| `{drive_id}` | `drive-id` | 1st | +| `{file_id}` | `file-id` | 2nd | + +Naming: always use `{resource}-id` format in kebab-case (e.g., `calendar-id`, `file-id`). Do not strip the `-id` suffix even when the resource name implies it — consistency across all commands is more important than brevity. + +### Rule 3: Verb Normalization + +OpenAPI operations use various naming conventions. Curated commands normalize to a fixed verb set: + +| OpenAPI Operation / Path Suffix | CLI Verb | Examples | +|-------------------------------|----------|----------| +| `GET /collection` | `list` | `calendar list`, `user list` | +| `GET /resource/{id}` | `get` | `calendar get`, `drive files get` | +| `POST /collection/create` | `create` | `calendar events create` | +| `POST /resource/{id}/update` | `update` | `calendar events update` | +| `POST /resource/{id}/delete` | `delete` | `calendar events delete` | +| `POST /collection/batch_create` | `batch-create` | `calendar events batch-create` | +| `POST /collection/batch_read` | `batch-get` | `user batch-get` | +| `POST /collection/batch_delete` | `batch-delete` | `drive files batch-delete` | +| `POST /subcollection/batch_create` | `add` | `calendar event-attendee add` | +| `POST /subcollection/batch_delete` | `remove` | `calendar event-attendee remove` | +| `GET /search` | `search` | `drive files search` | +| `POST /search` | `search` | `dbsheet records search` | +| Custom action | Action verb | `meeting end`, `im messages recall` | + +Key normalization decisions: +- **Write endpoints are POST, not PATCH/PUT**: WPS 365 OpenAPI uses POST for all mutations with explicit verb suffixes (`/create`, `/update`, `/delete`). The CLI reflects this — there are no PATCH/PUT curated commands. +- **Batch mutations on sub-collections use `add`/`remove`**: `batch_create` on a sub-collection (attendees, members) reads as "add to collection" from the user's perspective. +- **Mixed read verbs**: `POST /batch_read` → `batch-get`; `POST /search` → `search`. + +### Rule 4: Resource Nesting + +Sub-resources become nested command groups: + +``` +/v7/calendars/{id}/events → calendar events +/v7/calendars/{id}/events/{eid}/attendees → calendar event-attendee +/v7/meetings/{id}/participants → meeting participants +/v7/meetings/{id}/recordings → meeting recordings +/v7/drives/{did}/files/{fid}/permissions → drive file-permission +/v7/drives/{did}/files/{fid}/versions → drive file-version +``` + +Rules: +- Flatten deep nesting: `calendar event-attendee` not `calendar events attendee`. +- Use compound nouns with hyphens: `event-attendee`, `file-permission`, `room-level`. +- Maximum depth: 3 command segments (`resource sub-resource verb`). Deeper paths flatten the middle. + +### Rule 5: Non-Standard Path Patterns + +Some API paths don't follow the `/resource/{id}/action` pattern: + +| API Path | CLI Command | Mapping Strategy | +|----------|-------------|------------------| +| `/v7/users/current` | `user me` | Semantic alias for current user | +| `/v7/free_busy_list` | `calendar free-busy list` | Reassign to most relevant domain | +| `/v7/chats/get_p2p_chat` | `im p2p-chat get` | Action-as-resource pattern | +| `/v7/messages/batch_create` | `im messages send` | Semantic rename (send ≠ batch_create) | +| `/v7/drive_latest/items` | `drive recent-file list` | Reassign to resource context | +| `/v7/files/search` | `drive files search` | Cross-resource search assigned to primary domain | + +These require manual curation — automated mapping produces awkward results. + +## Query Parameter Mapping + +OpenAPI query parameters map to CLI flags with `to: query.param_name`: + +```yaml +flags: + - name: page-size + type: integer + default: 20 + to: query.page_size + - name: page-token + type: string + to: query.page_token +``` + +Naming: CLI uses kebab-case; query params use snake_case. The `to:` field handles the translation. + +## Request Body Mapping + +### Simple Fields + +Direct flag-to-field binding: + +```yaml +flags: + - name: name + type: string + description: 日程标题 +body: + bindings: + - from_flag: name + to: summary # CLI flag "name" → API field "summary" +``` + +### Nested Objects + +Use dot notation and array indexing: + +```yaml +body: + bindings: + - from_flag: text + to: content.text.content # Deep nesting + - from_flag: to + to: receivers[0].receiver_ids # Array element +``` + +### Type Coercion + +OpenAPI schemas declare types; the CLI enforces them: + +| OpenAPI Type | CLI Flag Type | Validation | +|-------------|---------------|------------| +| `string` | `string` | None | +| `integer` | `integer` | Must parse as int | +| `boolean` | `boolean` | Accepts true/false/1/0 | +| `array` | `string[]` | Repeatable flag (`--to u1 --to u2`) | +| `object` | `string` + `parse_json` | Accept JSON string | + +## Security Mapping + +OpenAPI `security` schemes determine auth requirements: + +```yaml +# OpenAPI spec +security: + - oauth2_delegated: [kso.calendar.read] + - oauth2_app: [kso.calendar.read] +``` + +This translates to: +- Both `delegated` and `app` auth modes accepted. +- The CLI validates that the current session has at least one compatible mode. +- Scope `kso.calendar.read` is checked against the token's granted scopes. + +When only one mode is declared: + +```yaml +security: + - oauth2_delegated: [kso.calendar.read] +``` + +The CLI requires delegated auth. Running with `--token-type app` produces an immediate error. + +## The `api` Escape Hatch + +Any endpoint not covered by curated commands is accessible via: + +```bash +wps365-cli api get "/v7/some/uncurated/endpoint" +wps365-cli api post "/v7/some/uncurated/endpoint" --data '{"key": "value"}' +``` + +The `api` command: +- **Reuses the auth session** — no separate login needed. +- **Supports all output formats** (`-o json|yaml|table|tsv`). +- **Supports `--dry-run`** — prints the request without sending. +- **No body mapping** — the user provides raw JSON via `--data`. + +This ensures 100% API coverage even before curation. + +## Custom Spec Extension + +Users can add curated commands for internal or custom endpoints: + +```bash +wps365-cli spec add --custom-curated ./my-commands.yaml +``` + +Custom specs are loaded after the official spec, allowing overrides and additions. See [spec-discovery.md](spec-discovery.md) for the full loading order. + +## Mapping Audit: Current Coverage + +The table below summarizes path-to-command mapping patterns across all 7 domains: + +| Pattern | Count | Example | +|---------|-------|---------| +| `GET /{resource}` → `list` | 18 | `calendar list` | +| `GET /{resource}/{id}` → `get` | 22 | `user get` | +| `POST /{resource}/create` → `create` | 21 | `calendar create` | +| `POST /{resource}/{id}/update` → `update` | 14 | `dbsheet sheets update` | +| `POST /{resource}/{id}/delete` → `delete` | 18 | `im chats delete` | +| `POST /{sub}/batch_create` → `add` | 8 | `calendar event-attendee add` | +| `POST /{sub}/batch_delete` → `remove` | 8 | `im chat-member remove` | +| `POST /batch_*` → `batch-*` | 12 | `user batch-get` | +| `GET/POST /search` → `search` | 5 | `drive files search` | +| Semantic alias | 6 | `user me`, `im messages send` | +| Action verb | 20 | `meeting end`, `im messages recall` | +| **Total** | **152** | | + +~649 paths are accessible only via `api get|post` (some curated commands share the same path with different method/auth, so the exact count varies by dedup method). + +## Comparison with Slock CLI (kscc) + +kscc does not use a spec-driven mapping system. Its commands are defined in TypeScript with explicit argument parsers. Key differences: + +| Aspect | wps365-cli | kscc | +|--------|-----------|------| +| Mapping source | Declarative YAML → runtime resolver | Imperative code | +| Adding commands | Edit spec YAML + restart | Write command module | +| API coverage | 152 curated + ~649 raw | 20 commands, no raw fallback | +| Body mapping | `bindings` + `transforms` | `zod` schema parsing | +| Auth validation | Spec-driven per-command | Global middleware | +| Agent integration | `--dry-run` + `-o json` | MCP protocol + hooks | + +The spec-driven approach trades flexibility for scalability — adding a command requires no code change, only a spec entry. kscc's code-first approach is more flexible for complex workflows (task lifecycle, thread attention) but harder to scale to 800+ endpoints. + +## Recommendations for Future Contributions + +1. **Prioritize high-traffic endpoints** — Check `api` command usage logs to find the most-used raw paths; curate those first. +2. **Preserve semantic names** — Don't blindly mirror the API path. `im messages send` is better than `im messages batch-create`. +3. **Minimize positional args** — Only resource IDs should be positional. Everything else is a flag. +4. **Document transforms** — When adding a new transform, add it to the transform registry and document the input/output types. +5. **Test with `--dry-run`** — Every new curated command should produce the correct HTTP request without actually sending it. diff --git a/docs/design-docs/spec-discovery.md b/docs/design-docs/spec-discovery.md new file mode 100644 index 0000000..f4a6a8e --- /dev/null +++ b/docs/design-docs/spec-discovery.md @@ -0,0 +1,358 @@ +# Spec 文件管理与加载机制 + +wps365-cli 基于 OpenAPI 3.0 规范文件驱动命令生成。本文档描述 spec 文件的存储位置、自动下载、增量更新与自定义覆盖的完整机制。 + +## 概述 + +CLI 在运行时读取两类 spec 文件: + +| 文件 | 路径 | 内容 | 行数 | +|------|------|------|------| +| API 规范 | `spec/api/365.yaml` | OpenAPI 3.0 全量接口定义(801 paths) | ~74k | +| 精装目录 | `spec/curated/365.yaml` | 精装命令的声明式目录(152 commands) | ~5k | + +此外还有自定义覆盖目录(详见[自定义覆盖](#自定义覆盖))。 + +## 存储位置 + +spec 文件存放在配置目录下,路径因操作系统而异: + +| OS | 路径 | +|----|------| +| macOS | `~/Library/Application Support/wps365-cli/spec/` | +| Linux | `~/.local/share/wps365-cli/spec/` 或 `$XDG_DATA_HOME/wps365-cli/spec/` | +| Windows | `%APPDATA%\wps365-cli\spec\` | + +目录结构: + +``` +spec/ +├── api/ +│ ├── 365.yaml # 官方 API 规范 +│ ├── 365.yaml.md5 # 缓存校验 +│ └── customs/ # 用户自定义 API 覆盖 +│ └── my-extension.yaml +├── curated/ +│ ├── 365.yaml # 官方精装目录 +│ ├── 365.yaml.md5 # 缓存校验 +│ └── customs/ # 用户自定义精装覆盖 +│ └── my-commands.yaml +└── osh/ # OSH 网关 spec(按需下载) + └── ... +``` + +## 自动下载 + +### 触发时机 + +当 CLI 执行任何精装命令时,内部调用 `EnsureLocalSpecs` → `ensureSpecs`,检查 spec 文件是否存在: + +- 两个 spec 文件都存在 → 跳过下载,直接加载 +- 任一文件缺失 → 自动从远程下载 + +可通过环境变量控制: + +```bash +# 禁止自动下载(缺省:true) +WPS365_SPEC_AUTO_DOWNLOAD=false +``` + +禁用后,若 spec 文件缺失,精装命令将不可用,但仍可使用 `api` 命令直接调用接口。 + +### 下载源 + +默认远程地址: + +``` +https://open.wps.cn/cli/specs/v1/{api,curated}/365.yaml +``` + +可通过环境变量覆盖: + +```bash +WPS365_SPEC_BASE_URL=https://your-mirror.example.com/specs +``` + +内部通过 `effectiveSpecBaseURL` 解析最终地址。 + +### 缓存与增量更新 + +每次下载后,CLI 将文件的 MD5 哈希存为 `.md5` 后缀文件。下次启动时 `checkSpecUpdates` 比对远程哈希: + +- 哈希未变 → 跳过下载 +- 哈希变化 → 重新下载并更新 `.md5` 文件 + +`specURLWithHash` 函数在下载 URL 后追加哈希后缀用于缓存控制。 + +### 手动更新 + +```bash +# 检查并下载最新 spec +wps365-cli spec update +``` + +## 加载流程 + +``` +用户执行命令 + │ + ├─ EnsureLocalSpecs() # 确保本地 spec 存在 + │ └─ ensureSpecs() # 缺失时触发自动下载 + │ + ├─ Load() / LoadWithOverrides() # 加载 spec 到内存 + │ ├─ loadAPISpec() # 解析 api/365.yaml + │ ├─ loadOfficialCatalog() # 解析 curated/365.yaml + │ ├─ loadOshCatalog() # 解析 osh/ 目录(如存在) + │ └─ loadCatalogDir() # 解析 customs/ 目录 + │ + └─ 命令执行 # 根据加载的 spec 路由到具体处理逻辑 +``` + +加载后,CLI 将 OpenAPI paths 注册为 `api` 子命令,将 curated commands 注册为语义化子命令。 + +## spec 子命令 + +| 命令 | 说明 | +|------|------| +| `spec update` | 检查远程更新并下载 | +| `spec set --api ` | 替换官方 API 规范文件 | +| `spec set --curated ` | 替换官方精装目录文件 | +| `spec add --custom-api ` | 添加自定义 API 覆盖到 customs 目录 | +| `spec add --custom-curated ` | 添加自定义精装覆盖到 customs 目录 | + +`spec set` 是替换操作——将官方 spec 替换为指定文件,CLI 后续使用替换后的版本。 + +`spec add` 是叠加操作——在官方 spec 基础上添加自定义覆盖,两者合并生效。 + +## 自定义覆盖 + +### API 覆盖 + +将 OpenAPI 3.0 格式的 YAML 文件放入 `spec/api/customs/` 目录(或使用 `spec add --custom-api`)。CLI 加载时会合并官方 spec 与所有 customs 文件。 + +适用场景: +- 补充官方 spec 尚未收录的接口 +- 覆盖官方 spec 中描述不准确的字段 +- 内部测试环境使用不同的接口定义 + +### 精装命令覆盖 + +将精装目录格式的 YAML 文件放入 `spec/curated/customs/` 目录(或使用 `spec add --custom-curated`)。CLI 加载时会合并官方目录与所有 customs 文件。 + +适用场景: +- 为高频接口添加更友好的命令别名 +- 为团队内部接口创建专用命令 +- 调整官方命令的默认参数或 body 绑定 + +### 自定义 API spec 格式要求 + +`spec/api/customs/` 下的文件必须符合 OpenAPI 3.0 规范。只需定义需要覆盖或补充的 paths,不需要重复官方 spec 中已有的内容。 + +示例——补充一个内部接口: + +```yaml +openapi: "3.0.0" +info: + title: Custom API extensions + version: "1.0" +paths: + /v7/internal/reports: + get: + summary: 获取内部报表 + operationId: getInternalReport + responses: + "200": + description: 成功 +``` + +### 自定义精装目录格式要求 + +`spec/curated/customs/` 下的文件必须遵循精装目录格式: + +```yaml +version: 1 +commands: + - id: my.resource.action + command: my resource action + summary: 我的自定义命令 + method: GET + path: /v7/my/resource + args: [] + flags: + - name: verbose + type: bool + required: false + to: query.verbose + body: + bindings: [] + examples: + - command: 'wps365-cli my resource action --verbose' +``` + +关键字段: +- `version` 必须为 `1` +- `id` 在所有目录中必须唯一,冲突时 customs 优先 +- `command` 定义 CLI 命令路径(空格分隔资源层级) +- `body.bindings` 的 `transform` 支持:`split_csv`、`to_int`、`to_bool`、`parse_json`、`trim`、`wrap`、`negate` + +### 合并优先级 + +当官方 spec 与 customs 存在相同 ID 的定义时,customs 中的定义整体替换官方定义(非字段级合并)。多个 customs 文件存在相同 ID 时,按文件名字典序排列,后者覆盖前者。 + +完整优先级(从低到高): + +| 优先级 | 来源 | 说明 | +|--------|------|------| +| 1 | 内嵌 spec | 编译到二进制中的官方 spec | +| 2 | 本地官方 spec | `spec/api/365.yaml` / `spec/curated/365.yaml` | +| 3 | 自定义 spec | `customs/` 目录中的文件,按文件名字典序 | + +- **精装目录**:以 `id` 为键,customs 中的命令整体替换同 id 的官方命令(非字段级合并) +- **API 规范**:以 path 为键,customs 中的 path 定义覆盖同 path 的官方定义(path 级替换,非字段级合并) + +### 自定义精装命令编写格式 + +customs 目录中的 YAML 文件与官方精装目录格式相同: + +```yaml +version: 1 +commands: + - id: myteam.deploy.notify + command: myteam deploy notify + summary: 部署完成通知 + description: 部署完成后向指定用户发送 IM 通知 + method: POST + path: /v7/messages/batch_create + flags: + - name: to + type: string[] + required: true + description: 接收人 open_id 列表 + - name: text + type: string + required: true + description: 通知内容 + body: + defaults: + type: text + receivers[0].type: user + content.text.type: plain + bindings: + - from_flag: to + to: receivers[0].receiver_ids + - from_flag: text + to: content.text.content + examples: + - command: 'wps365-cli myteam deploy notify --to ou_abc --text "v2.3 已上线"' + description: 部署完成后发送通知 +``` + +编写规则: + +1. **`id` 全局唯一** — 官方命令使用 `{domain}.{resource}.{verb}` 格式。自定义命令建议加组织前缀(如 `myteam.`)避免与官方冲突。 +2. **`path` 必须存在于 API spec** — 如果是内部 API,需同时在 `spec/api/customs/` 中添加对应路径定义。 +3. **`method` 必须与 API spec 一致** — GET 路径写 POST 会运行时报错。 +4. **body 绑定使用点记法** — `content.text.content` 对应 JSON 嵌套层级。 +5. **数组索引从 0 开始** — `receivers[0].receiver_ids` 对应第一个数组元素。 +6. **transform 支持 pipe 组合** — 详见 [curated-commands.md](curated-commands.md) 的 Transform Pipeline 章节。 +7. **`request_schema_ref` 和 `response_schema_ref` 可选** — 非所有命令都有 schema 引用,但推荐添加以增强 `--help` 输出的参数说明。 + +### 覆盖官方命令 + +在 customs 文件中使用与官方命令相同的 `id`,即可覆盖其定义: + +```yaml +version: 1 +commands: + - id: calendar.list + command: calendar list + summary: 查询日历列表(默认50条) + method: GET + path: /v7/calendars + flags: + - name: page-size + type: integer + default: 50 # 覆盖官方默认值 20 + description: 每页返回的日历数量 + to: query.page_size +``` + +## OSH 网关 Spec + +OSH(企业网关)模式的 spec 处理与标准模式不同: + +- 通过 `pullOSHSpecs` / `syncOSHZip` 单独下载 +- 以 ZIP 格式传输,本地解压 +- 存放在 `spec/osh/` 目录下 +- 由 `loadOshCatalog` 加载 + +OSH spec 的下载受 OSH 认证状态控制,仅在 `auth login --osh` 后才可获取。 + +## 调试与排查 + +### 命令未找到 + +``` +Error: unknown command "calendar events create" +``` + +排查步骤: +1. `wps365-cli spec status` — 确认 curated spec 存在且来源正确 +2. `wps365-cli calendar --help` — 检查已注册的子命令 +3. 若自定义 spec 未生效,检查 `id` 是否拼写正确、文件名排序是否靠后 + +### 请求路径 404 + +``` +Error: API returned 404 for POST /v7/calendars/{calendar_id}/events/create +``` + +排查步骤: +1. 确认 API spec 中存在该路径:`grep "/v7/calendars" spec/api/365.yaml` +2. 确认 `method` 是否正确 +3. 运行 `spec update` 确保是最新 spec + +### Body 映射不生效 + +排查步骤: +1. 使用 `--dry-run -o json` 查看实际构造的请求体 +2. 检查 `to:` 字段的点记法是否与 API schema 匹配 +3. 检查 transform 是否正确(如 `parse_json` 需要合法 JSON 输入) +4. 检查 `defaults` 中是否有覆盖 flag 绑定的值 + +### 自定义 spec 未被加载 + +排查步骤: +1. `wps365-cli spec status` — 确认 `custom_count` 不为 0 +2. 确认文件在正确目录(`customs/` 而非根 spec 目录) +3. 确认文件格式为合法 YAML(注意缩进和 `version: 1` 顶层声明) +4. 多个 customs 文件存在同名命令时,文件名字典序靠后的优先 + +## 环境变量速查 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `WPS365_SPEC_AUTO_DOWNLOAD` | `true` | 是否自动下载缺失的 spec | +| `WPS365_SPEC_BASE_URL` | `https://open.wps.cn/cli/specs/v1` | spec 远程下载地址 | +| `WPS365_CONFIG_DIR` | 系统默认 | 配置目录(含 spec 子目录) | + +> **注意区分**:`WPS365_SPEC_BASE_URL` 控制 spec 文件的下载源(YAML 规范文件从哪里拉取),而 `WPS365_API_BASE` 控制 API 请求的目标端点(运行时 HTTP 请求发往哪里)。两者相互独立——可以使用官方 spec 描述文件,同时将 API 请求指向内部测试环境。 + +## 实现包 + +核心逻辑位于 `wps365-cli/internal/specfile` 包: + +| 函数 | 职责 | +|------|------| +| `EnsureLocalSpecs` | 入口:确保本地 spec 就绪 | +| `ensureSpecs` | 检查并触发下载 | +| `checkSpecUpdates` | 增量更新检查 | +| `downloadSpec` | 下载单个 spec 文件 | +| `specURLWithHash` | 构造带哈希的下载 URL | +| `effectiveSpecBaseURL` | 解析远程地址 | +| `Load` / `LoadWithOverrides` | 加载并合并所有 spec | +| `loadAPISpec` | 解析 OpenAPI 3.0 YAML | +| `loadOfficialCatalog` | 解析精装目录 | +| `loadOshCatalog` | 解析 OSH 目录 | +| `loadCatalogDir` | 解析 customs 目录 | +| `pullOSHSpecs` / `syncOSHZip` | OSH spec 下载与解压 | diff --git a/docs/design-docs/testing.md b/docs/design-docs/testing.md new file mode 100644 index 0000000..d2e701e --- /dev/null +++ b/docs/design-docs/testing.md @@ -0,0 +1,313 @@ +# 测试策略与 E2E 约束 + +wps365-cli 是闭源二进制分发,仓库中不包含测试代码。本文档描述 CLI 的测试约束、可用的验证手段,以及针对开源贡献场景的测试建议。 + +## 约束 + +### 1. 闭源二进制,无单元测试可参考 + +Go 源码未公开,无法直接运行或调试单元测试。所有行为观测依赖黑盒方式——运行二进制、检查输出与退出码。 + +### 2. 需要真实企业账号 + +WPS365 开放平台 API 要求: +- 已创建的企业自建应用 +- 已申请并通过审批的 API 权限 +- 有效的 OAuth 凭证(client_id + client_secret) + +没有企业账号,无法完成端到端调用。 + +### 3. OAuth 授权流程需要浏览器 + +`auth login --scopes "..."` 启动本地 HTTP 服务监听回调,需要用户在浏览器中完成授权。这不适合 CI/CD 环境,也无法在无头环境中自动完成。 + +App 模式登录(`auth login --app`)不依赖浏览器,可在 CI/CD 中使用: +```bash +export WPS365_CLIENT_ID="" +export WPS365_CLIENT_SECRET="" +wps365-cli auth login --app +``` + +### 4. API 调用有速率限制 + +WPS365 开放平台对 API 调用有频率限制。自动化测试若短时间大量调用,可能触发限流导致误判。 + +### 5. 接口依赖特定数据状态 + +许多接口需要特定的前置数据才能正常返回。例如: +- 日历操作需要已存在的日历 ID +- 群聊操作需要有效的群 ID +- 文件操作需要有效的 drive ID 和文件 ID + +没有这些前置数据,接口返回 404 或业务错误,不代表 CLI 本身有 bug。 + +## 验证手段 + +### Dry Run + +`--dry-run` 是最核心的测试工具。它使 CLI 构造请求但不实际发送,输出将要发出的 HTTP 请求详情: + +```bash +# 验证精装命令的请求构造 +wps365-cli --dry-run calendar events create primary \ + --name "周会" --from "2024-01-15T14:00:00+08:00" --to "2024-01-15T15:00:00+08:00" + +# 验证 api 命令的路径和参数 +wps365-cli --dry-run api get "/v7/users/current" + +# 验证 body 序列化 +wps365-cli --dry-run -o json im messages send --to u1 --text "hello" +``` + +dry-run 输出包含: +- HTTP 方法与路径 +- 请求头(含 Authorization 类型) +- 查询参数 +- 请求 body(JSON) +- 目标 API base URL + +这足以验证: +- 命令参数到 API 参数的映射是否正确 +- body 绑定与 transform 管道是否生效 +- 认证模式选择是否符合 security 约束 + +#### 当前限制 + +当前版本(v0.1.0)的 dry-run 在未登录状态下返回 "not logged in" 错误(exit code 5),而非直接输出请求构造。这意味着 dry-run 仍需有效的认证状态才能使用。在 CI/CD 场景中,可先用 `auth login --app` 建立 app token,再运行 dry-run 验证。 + +### 结构化输出 + +`-o` 参数控制输出格式,可用于断言: + +```bash +# JSON 输出,适合 jq 断言 +wps365-cli -o json user me + +# YAML 输出 +wps365-cli -o yaml calendar list + +# 表格输出,适合人工检查 +wps365-cli -o table calendar list + +# TSV 输出,适合管道处理 +wps365-cli -o tsv calendar list | cut -f2 +``` + +### 退出码 + +| 退出码 | 含义 | +|--------|------| +| 0 | 成功 | +| 1 | 一般错误 | +| 2 | 参数错误(无效 flag、缺少必需参数等) | +| 5 | 未登录 / 认证失败 | +| 7 | 网络错误(连接超时、DNS 解析失败等) | + +脚本可通过退出码判断命令是否成功,无需解析输出。 + +### 静默模式 + +`--quiet` 或 `WPS365_QUIET=true` 抑制 stderr 信息输出,仅保留 stdout 数据。这避免了信息文本对输出解析的干扰。 + +## 测试策略建议 + +### 第一层:Dry Run 验证(无需真实数据) + +在 app token 建立后,用 dry-run 验证命令参数映射: + +```bash +# 建立 app token(一次性) +export WPS365_CLIENT_ID="" +export WPS365_CLIENT_SECRET="" +wps365-cli auth login --app + +# 验证精装命令 +wps365-cli --dry-run -o json calendar events create primary \ + --name "测试" --from "2024-01-15T14:00:00+08:00" --to "2024-01-15T15:00:00+08:00" +``` + +断言 dry-run 输出中的: +- HTTP method 为 POST +- path 包含 `/v7/calendars/primary/events/create` +- body 中 `summary` 字段为 "测试" +- body 中 `start_time` 字段正确 + +这类测试可在 CI/CD 中运行,不需要特定数据状态。 + +### 第二层:只读接口验证(需要真实账号) + +对只读接口做轻量端到端验证: + +```bash +wps365-cli user me +wps365-cli calendar list +wps365-cli -o json user me | jq '.data.user_id' +``` + +断言: +- 退出码为 0 +- 输出为合法 JSON +- 关键字段存在且类型正确 + +注意控制调用频率,避免触发限流。 + +### 第三层:写入接口验证(手动 / 隔离环境) + +写入接口(创建日程、发送消息等)应在隔离测试环境中进行,手动验证。建议: +- 使用专门的测试应用,避免污染生产数据 +- 每次测试后清理创建的资源 +- 记录请求与响应用于回归比对 + +#### 幂等测试模式 + +对于写入接口,可采用"创建-验证-删除"模式确保测试不留下残留数据: + +```bash +# 创建日程 +EVENT_ID=$(wps365-cli -o json calendar events create primary \ + --name "测试日程" --from "2024-01-15T14:00:00+08:00" --to "2024-01-15T15:00:00+08:00" \ + | jq -r '.data.event_id') + +# 验证日程存在 +wps365-cli calendar events get primary "$EVENT_ID" + +# 清理:删除测试日程 +wps365-cli calendar events delete primary "$EVENT_ID" +``` + +此模式要求删除接口可用。若接口不支持删除(如发送消息),则应在专门的测试群聊中执行,并标记测试数据以便后续人工清理。 + +### CI/CD 集成示例 + +以下 GitHub Actions 工作流展示如何在 PR 中自动验证文档引用的命令参数是否正确: + +```yaml +name: CLI dry-run validation +on: pull_request +jobs: + dry-run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install wps365-cli + run: curl -fsSL https://raw.githubusercontent.com/wps365-open/cli/main/install.sh | bash + - name: Login as app + env: + WPS365_CLIENT_ID: ${{ secrets.WPS365_CLIENT_ID }} + WPS365_CLIENT_SECRET: ${{ secrets.WPS365_CLIENT_SECRET }} + run: wps365-cli auth login --app + - name: Validate calendar command + run: | + wps365-cli --dry-run -o json calendar events create primary \ + --name "CI test" --from "2024-01-15T14:00:00+08:00" \ + --to "2024-01-15T15:00:00+08:00" \ + | jq -e '.method == "POST" and (.path | contains("/v7/calendars/primary/events/create"))' + - name: Validate user command + run: | + wps365-cli --dry-run -o json user me \ + | jq -e '.method == "GET" and (.path == "/v7/users/current")' +``` + +`jq -e` 标志使 jq 在表达式结果为 `false` 或 `null` 时以 exit 1 退出,搭配 `set -e` 可实现断言效果——表达式为真则继续,为假则中断流水线。 + +## 文档贡献的测试 + +本仓库当前主要接受文档贡献。文档测试的重点是: + +1. **链接可达性**:所有文档内链接(特别是指向 `docs/design-docs/` 下其他文档的引用)应指向实际存在的文件 +2. **命令准确性**:文档中出现的 CLI 命令和参数应与实际行为一致,可用 `--help` 或 `--dry-run` 交叉验证 +3. **示例可复现**:代码块中的示例在具备凭证的前提下应可运行 + +### 链接检查脚本 + +```bash +#!/usr/bin/env bash +# 检查 markdown 文件中的内部链接是否指向存在的文件 +grep -roP '\[.*?\]\(([^)]+)\)' docs/ README.md | \ + sed 's/.*](\([^)]*\))/\1/' | \ + grep -v '^http' | \ + while read -r link; do + target="$(dirname "$link")/$(basename "$link" | sed 's/#.*//')" + [ -f "$target" ] || echo "BROKEN: $link" + done +``` + +### Spec 完整性检查 + +验证精装命令引用的路径和 schema 在 API spec 中均存在: + +```bash +# 验证所有精装命令的 path 在 API spec 中存在 +grep -oP 'path: \K.*' spec/curated/365.yaml | sort -u | while read -r p; do + escaped_path=$(echo "$p" | sed 's/{[^}]*}/[^\/]+/g') + if ! grep -qP "^\s+\"?$escaped_path\"?:" spec/api/365.yaml; then + echo "BROKEN PATH: $p" + fi +done + +# 验证 schema 引用指向存在的组件 +grep -oP '(request|response)_schema_ref: \K.*' spec/curated/365.yaml | sort -u | while read -r ref; do + component=$(echo "$ref" | sed 's|#/components/schemas/||') + if ! grep -qP "^\s+${component}:" spec/api/365.yaml; then + echo "BROKEN SCHEMA: $ref" + fi +done +``` + +### Dry-run 断言示例 + +以下示例使用 `jq -e`,其中 `-e` 标志表示当表达式结果为 `false` 或 `null` 时 jq 以退出码 1 退出,便于脚本断言失败时中断。 + +```bash +# 断言请求方法 +wps365-cli --dry-run -o json calendar events create primary \ + --name "测试" --from "2024-01-15T14:00:00+08:00" --to "2024-01-15T15:00:00+08:00" \ + | jq -e '.method == "POST"' + +# 断言 transform 结果 +wps365-cli --dry-run -o json calendar events create primary \ + --name "测试" --from "2024-01-15T14:00:00+08:00" --to "2024-01-15T15:00:00+08:00" \ + --reminders "30,10" \ + | jq -e '.body.reminders | length == 2' + +# 断言默认值注入 +wps365-cli --dry-run -o json im messages send \ + --to ou_abc --text "hello" \ + | jq -e '.body | {type, "receivers_type": .receivers[0].type}' +# 期望: {"type": "text", "receivers_type": "user"} +``` + +## CI/CD 集成 + +### GitHub Actions 示例 + +```yaml +name: CLI 冒烟测试 +on: [push, pull_request] +jobs: + dry-run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: 安装 wps365-cli + run: curl -fsSL https://raw.githubusercontent.com/wps365-open/cli/main/install.sh | bash + - name: 配置应用凭证 + env: + WPS365_CLIENT_ID: ${{ secrets.WPS365_CLIENT_ID }} + WPS365_CLIENT_SECRET: ${{ secrets.WPS365_CLIENT_SECRET }} + run: wps365-cli auth login --app + - name: Dry-run 冒烟测试 + run: | + wps365-cli --dry-run user me + wps365-cli --dry-run calendar list + wps365-cli --dry-run api get "/v7/users/current" + - name: 验证 spec 完整性 + run: | + SPEC_DIR=$(wps365-cli spec path) + grep -oP 'path: \K.*' "$SPEC_DIR/curated/365.yaml" | sort -u | while read -r p; do + escaped_path=$(echo "$p" | sed 's/{[^}]*}/[^\/]+/g') + if ! grep -qP "^\s+\"?$escaped_path\"?:" "$SPEC_DIR/api/365.yaml"; then + echo "BROKEN: $p not found in API spec" && exit 1 + fi + done +``` diff --git a/docs/faq/faq.md b/docs/faq/faq.md new file mode 100644 index 0000000..2eeeed9 --- /dev/null +++ b/docs/faq/faq.md @@ -0,0 +1,69 @@ +# 常见问题 + +## 多维表(DbSheet) + +### 获取不了在线表格中的图片链接? + +多维表中的图片和附件需要两步获取: + +**第一步:获取记录中的 attachment_id** + +```bash +wps365-cli dbsheet records list --fields "图片字段名" +``` + +返回的记录中,附件类型字段的值是 JSON 字符串,其中包含 `attachment_id`。 + +**第二步:通过附件接口获取下载链接** + +```bash +wps365-cli api get "/v7/coop/dbsheet//attachments/" +``` + +响应中的 `data.attachment.value.download_url` 即为图片/附件的下载链接。 + +**注意事项:** +- 需要 `kso.dbsheet.read` 或 `kso.dbsheet.readwrite` 权限 +- `delegated` 和 `app` 两种认证模式均支持 +- 下载链接可能有时效限制,建议获取后及时使用 + +> 目前 `dbsheet attachments get` 尚未收录为精装命令,需通过 `api get` 调用。后续版本将支持直接使用精装命令。 + +## 个人版 + +### 个人版能否使用 wps365-cli? + +wps365-cli 依赖 WPS 365 开放平台的 OAuth2 认证体系,需要企业版管理员创建应用并审批权限。个人版用户暂无法使用。 + +详见 [前置准备:创建应用与权限配置](prerequisites.md)。 + +## 认证与权限 + +### 权限申请后为什么没有生效? + +权限申请后需要完成「创建版本 → 申请发布 → 企业管理员审批」的完整流程才会生效。仅申请权限而不提交版本审批,权限会一直处于「待提交审核」状态。 + +详见 [前置准备:创建应用与权限配置](prerequisites.md)。 + +### CLIENT_SECRET 泄露了怎么办? + +立即在开发者后台「应用凭证」区域点击「重置」生成新密钥,旧密钥立即失效。然后重新执行 `wps365-cli auth setup` 配置新凭证。 + +## Agent 集成 + +### AI Agent 如何调用 wps365-cli? + +wps365-cli 提供两个 Agent 友好特性: + +- `--dry-run`:预览请求而不实际发送,适合 Agent 试探式调用 +- `-o json`:结构化 JSON 输出,方便 Agent 解析 + +```bash +# Agent 先预览请求 +wps365-cli --dry-run -o json user me + +# 确认无误后实际执行 +wps365-cli -o json user me +``` + +> 更深度的 Agent 集成(如 MCP 协议适配)正在规划中,欢迎关注后续更新。 diff --git a/docs/prerequisites.md b/docs/prerequisites.md index a60b5fd..25ae499 100644 --- a/docs/prerequisites.md +++ b/docs/prerequisites.md @@ -2,6 +2,8 @@ 使用 wps365-cli 之前,需要先在 WPS 365 开放平台完成应用创建、权限申请与企业审批。本文档将逐步引导你完成全部准备工作。 +> **前提条件**:wps365-cli 需要 **WPS 365 企业版** 账号。个人版(含 VIP)不支持开放平台 API,无法使用本工具。如果你不确定自己的账号类型,请登录 [WPS 365 开放平台](https://open.wps.cn/developer) 验证是否能正常进入开发者后台。 + ## 整体流程 ``` @@ -93,6 +95,10 @@ http://localhost:18365/callback ## 常见问题 +### Q:个人版或 VIP 用户可以使用 wps365-cli 吗? + +不可以。WPS 365 开放平台 API 仅面向企业版用户开放,个人版(含 WPS VIP 会员)无法创建企业自建应用,因此无法获取 `client_id` 和 `client_secret`。如需使用,请联系你的组织管理员开通 WPS 365 企业版。 + ### Q:权限申请后为什么没有生效? 权限申请后需要完成「创建版本 → 申请发布 → 企业管理员审批」的完整流程才会生效。仅申请权限而不提交版本审批,权限会一直处于「待提交审核」状态。