commit 83dfb068ad8bb4052787d80ca415118a20849b85 Author: 梁硕 Date: Sat Mar 28 10:36:25 2026 +0800 feat: open-source lark-cli — the official CLI for Lark/Feishu Change-Id: I113d9cdb5403cec347efe4595415e34a18b7decf diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..5aba7b0d --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,36 @@ +name: Coverage + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + codecov: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + + - name: Run tests with coverage + run: go test -coverprofile=coverage.txt -covermode=atomic ./... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 + with: + files: coverage.txt + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..2d0da6b6 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,72 @@ +name: Lint + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + staticcheck: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Fetch meta_data.json + run: python3 scripts/fetch_meta.py + + - name: Run staticcheck + uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1 + with: + install-go: false + + golangci-lint: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Fetch meta_data.json + run: python3 scripts/fetch_meta.py + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6 + with: + version: latest + + vet: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Fetch meta_data.json + run: python3 scripts/fetch_meta.py + + - name: Run go vet + run: go vet ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..94d6b56a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + goreleaser: + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..11136dcf --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + unit-test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + + - name: Run tests + run: go test -v -race -count=1 -timeout=30s ./cmd/... ./internal/... ./shortcuts/... diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ec525ba8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Build output +/lark-cli +.cache/ +dist/ +bin/ + +# Node +node_modules/ + + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Go +docs/ref +docs/ +vendor/ + + +#test +test_scripts/ +tests/mail/reports/ + +/log/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 00000000..4040cc17 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,40 @@ +version: 2 + +before: + hooks: + - python3 scripts/fetch_meta.py + +builds: + - binary: lark-cli + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }} + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + +archives: + - name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}" + format_overrides: + - goos: windows + format: zip + files: + - README.md + - LICENSE + - CHANGELOG.md + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' diff --git a/ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf b/ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf new file mode 100644 index 00000000..043c0aa0 Binary files /dev/null and b/ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..cad474a1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [v1.0.0] - 2026-03-28 + +### Initial Release + +The first open-source release of **Lark CLI** — the official command-line interface for [Lark/Feishu](https://www.larksuite.com/). + +### Features + +#### Core Commands + +- **`lark api`** — Make arbitrary Lark Open API calls directly from the terminal with flexible parameter support. +- **`lark auth`** — Complete OAuth authentication flow, including interactive login, logout, token status, and scope management. +- **`lark config`** — Manage CLI configuration, including `init` for guided setup and `default-as` for switching contexts. +- **`lark schema`** — Inspect available API services and resource schemas. +- **`lark doctor`** — Run diagnostic checks on CLI configuration and environment. +- **`lark completion`** — Generate shell completion scripts for Bash, Zsh, Fish, and PowerShell. + +#### Service Shortcuts + +Built-in shortcuts for commonly used Lark APIs, enabling concise commands like `lark im send` or `lark drive upload`: + +- **IM (Messaging)** — Send messages, manage chats, and more. +- **Drive** — Upload, download, and manage cloud documents. +- **Docs** — Work with Lark documents. +- **Sheets** — Interact with spreadsheets. +- **Base (Bitable)** — Manage multi-dimensional tables. +- **Calendar** — Create and manage calendar events. +- **Mail** — Send and manage emails. +- **Contact** — Look up users and departments. +- **Task** — Create and manage tasks. +- **Event** — Subscribe to and manage event callbacks. +- **VC (Video Conference)** — Manage meetings. +- **Whiteboard** — Interact with whiteboards. + +#### AI Agent Skills + +Bundled AI agent skills for intelligent assistance: + +- `lark-im`, `lark-doc`, `lark-drive`, `lark-sheets`, `lark-base`, `lark-calendar`, `lark-mail`, `lark-contact`, `lark-task`, `lark-event`, `lark-vc`, `lark-whiteboard`, `lark-wiki`, `lark-minutes` +- `lark-openapi-explorer` — Explore and discover Lark APIs interactively. +- `lark-skill-maker` — Create custom AI skills. +- `lark-workflow-meeting-summary` — Automated meeting summary workflow. +- `lark-workflow-standup-report` — Automated standup report workflow. +- `lark-shared` — Shared skill utilities. + +#### Developer Experience + +- Cross-platform support (macOS, Linux, Windows) via GoReleaser. +- Shell completion for Bash, Zsh, Fish, and PowerShell. +- Bilingual documentation (English & Chinese). +- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. + +[v1.0.0]: https://github.com/larksuite/cli/releases/tag/v1.0.0 diff --git a/CLA.md b/CLA.md new file mode 100644 index 00000000..4f186c6b --- /dev/null +++ b/CLA.md @@ -0,0 +1,28 @@ +> Thank you for your interest in open source projects hosted or managed by ByteDance Ltd. and/or its Affiliates ("**ByteDance**") . In order to clarify the intellectual property license granted with Contributions from any person or entity, ByteDance must have a Contributor License Agreement ("**CLA**") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of ByteDance and its users; it does not change your rights to use your own Contributions for any other purpose. +> If you are an individual making a submission on your own behalf, you should accept the Individual Contributor License Agreement. If you are making a submission on behalf of a legal entity (the “**Corporation**”), you should sign the separation Corporate Contributor License Agreement. + +**ByteDance Individual Contributor License Agreement v1.** **1** +By clicking “Accept” on this page, You accept and agree to the following terms and conditions for Your present and future Contributions submitted to ByteDance. Except for the license granted herein to ByteDance and recipients of software distributed by ByteDance, You reserve all right, title, and interest in and to Your Contributions. +1.Definitions. +"Affiliate" shall mean an entity that Controls, is Controlled by, or is under common Control with You or ByteDance, respectively (but only as long as such Control exists). +"Control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. +"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to ByteDance for inclusion in, or documentation of, any of the products owned or managed by ByteDance (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to ByteDance or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, ByteDance for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." +"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with ByteDance. For the avoidance of doubt, the Corporation making a Contribution and all of its Affiliates are considered to be a single Contributor and this CLA shall apply to Contributions Submitted by the Corporation or any of its Affiliates. +2.Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. +3.Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. +4.You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to ByteDance, or that your employer has executed a separate Corporate CLA with ByteDance. +5.You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. +6.You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. +7.Should You wish to submit work that is not Your original creation, You may submit it to ByteDance separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". +8.You agree to notify ByteDance of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. +9.You agree that contributions to Projects and information about contributions may be maintained indefinitely and disclosed publicly, including Your name and other information that You submit with your submission. +10.This Agreement is the entire agreement and understanding between the parties, and supersedes any and all prior agreements, understandings or communications, written or oral, between the parties relating to the subject matter hereof. This Agreement may be assigned by ByteDance. + +[ByteDance Corporate Contributor License Agreement v1.1](./ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf) + +This version of the Contributor License Agreement allows a legal entity (the “Corporation”) to submit Contributions to the applicable project. +ByteDance Corporate Contributor License Agreement v1.1.pdf +A person authorized to sign legal documents on behalf of your employer (usually a VP or higher) must sign the Contributor Agreement on behalf of the employer. +If you have not already signed this agreement, please complete and sign, then scan and email a pdf file of this Agreement to opensource-cla@bytedance.com. Please read this document carefully before signing and keep a copy for your records. + +If you need to update your CLA, please email  from the email address associated with your individual or corporate information. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..2b8bbb5e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lark Technologies Pte. Ltd. + +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/Makefile b/Makefile new file mode 100644 index 00000000..7d78c510 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT + +BINARY := lark-cli +MODULE := github.com/larksuite/cli +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +DATE := $(shell date +%Y-%m-%d) +LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) +PREFIX ?= /usr/local + +.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta + +fetch_meta: + python3 scripts/fetch_meta.py + +build: fetch_meta + go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) . + +vet: fetch_meta + go vet ./... + +unit-test: fetch_meta + go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/... + +integration-test: build + go test -v -count=1 ./tests/... + +test: vet unit-test integration-test + +install: build + install -d $(PREFIX)/bin + install -m755 $(BINARY) $(PREFIX)/bin/$(BINARY) + @echo "OK: $(PREFIX)/bin/$(BINARY) ($(VERSION))" + +uninstall: + rm -f $(PREFIX)/bin/$(BINARY) + +clean: + rm -f $(BINARY) diff --git a/README.md b/README.md new file mode 100644 index 00000000..d431d69b --- /dev/null +++ b/README.md @@ -0,0 +1,268 @@ +# lark-cli + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-blue.svg)](https://go.dev/) + +[中文版](./README.zh.md) | [English](./README.md) + +A command-line tool for [Lark/Feishu](https://www.larksuite.com/) Open Platform — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 19 AI Agent [Skills](./skills/). + +[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing) + +## Why lark-cli? + +- **Agent-Native Design** — 19 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup +- **Wide Coverage** — 11 business domains, 200+ curated commands, 19 AI Agent [Skills](./skills/) +- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates +- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install` +- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps +- **Secure & Controllable** — Input injection protection, terminal output sanitization, OS-native keychain credential storage +- **Three-Layer Architecture** — Shortcuts (human & AI friendly) → API Commands (platform-synced) → Raw API (full coverage), choose the right granularity + +## Features + +| Category | Capabilities | +| ------------- | ----------------------------------------------------------------------------------- | +| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions | +| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media | +| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards | +| 📁 Drive | Upload and download files, search docs & wiki, manage comments | +| 📊 Base | Create and manage tables, fields, records, views, dashboards, data aggregation & analytics | +| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data | +| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders | +| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents | +| 👤 Contact | Search users by name/email/phone, get user profiles | +| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail | +| 🎥 Meetings | Search meeting records, query meeting minutes & recordings | + +## Installation & Quick Start + +### Requirements + +Before you start, make sure you have: + +- Node.js (`npm`/`npx`) +- Go `v1.23`+ and Python 3 (only required for building from source) + +### Quick Start (Human Users) + +> **Tip:** If you have an AI Agent, you can hand this README to it and let the AI handle installation and setup — jump to [Quick Start (AI Agent)](#quick-start-ai-agent). + +#### Install CLI + +**From npm (recommended):** + +```bash +npm install -g @larksuite/cli +``` + +**From source:** + +```bash +make install +``` + +#### Install AI Agent Skills + +[Skills](./skills/) are structured instruction documents that enable AI Agents to use this CLI: + +```bash +# Install all skills to current directory +npx skills add larksuite/cli -y + +# Install all skills globally +npx skills add larksuite/cli -y -g +``` + +#### Configure & Use + +```bash +# 1. Configure app credentials (one-time, interactive guided setup) +lark-cli config init + +# 2. Log in (--recommend auto-selects commonly used scopes) +lark-cli auth login --recommend + +# 3. Start using +lark-cli calendar +agenda +``` + +## Quick Start (AI Agent) + +> The following steps are for AI Agents. Some steps require the user to complete actions in a browser. + +```bash +# 1. Install CLI +npm install -g @larksuite/cli + +# 2. Install Skills (enables AI Agent to use this CLI) +npx skills add larksuite/cli --all -y + +# 3. Configure app credentials +# Important: run this command in the background. It will output an authorization URL — extract it and send it to the user. The command exits automatically after the user completes the setup in browser. +lark-cli config init --new + +# 4. Login +# Same as above: run in the background, extract the authorization URL and send it to the user. +lark-cli auth login --recommend + +# 5. Verify +lark-cli auth status +``` + +## Agent Skills + +| Skill | Description | +| ------------------------------- | ------------------------------------------------------------------------------------- | +| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) | +| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions | +| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions | +| `lark-doc` | Create, read, update, search documents (Markdown-based) | +| `lark-drive` | Upload, download files, manage permissions & comments | +| `lark-sheets` | Create, read, write, append, find, export spreadsheets | +| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics | +| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment | +| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail | +| `lark-contact` | Search users by name/email/phone, get user profiles | +| `lark-wiki` | Knowledge spaces, nodes, documents | +| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format | +| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) | +| `lark-whiteboard` | Whiteboard/chart DSL rendering | +| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) | +| `lark-openapi-explorer` | Explore underlying APIs from official docs | +| `lark-skill-maker` | Custom skill creation framework | +| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report | +| `lark-workflow-standup-report` | Workflow: agenda & todo summary | + +## Authentication + +| Command | Description | +| ------------- | -------------------------------------------------------------- | +| `auth login` | OAuth login with interactive selection or CLI flags for scopes | +| `auth logout` | Sign out and remove stored credentials | +| `auth status` | Show current login status and granted scopes | +| `auth check` | Verify a specific scope (exit 0 = ok, 1 = missing) | +| `auth scopes` | List all available scopes for the app | +| `auth list` | List all authenticated users | + +```bash +# Interactive login (TUI guides domain and permission level selection) +lark-cli auth login + +# Filter by domain +lark-cli auth login --domain calendar,task + +# Recommended auto-approval scopes +lark-cli auth login --recommend + +# Exact scope +lark-cli auth login --scope "calendar:calendar:readonly" + +# Agent mode: return verification URL immediately, non-blocking +lark-cli auth login --domain calendar --no-wait +# Resume polling later +lark-cli auth login --device-code + +# Identity switching: execute commands as user or bot +lark-cli calendar +agenda --as user +lark-cli im +messages-send --as bot --chat-id "oc_xxx" --text "Hello" +``` + +## Three-Layer Command System + +The CLI provides three levels of granularity, covering everything from quick operations to fully custom API calls: + +### 1. Shortcuts + +Prefixed with `+`, designed to be friendly for both humans and AI, with smart defaults, table output, and dry-run previews. + +```bash +lark-cli calendar +agenda +lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello" +lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X" +``` + +Run `lark-cli --help` to see all shortcut commands. + +### 2. API Commands + +Auto-generated from Lark OAPI metadata, curated through evaluation and quality gates — 100+ commands mapped 1:1 to platform endpoints. + +```bash +lark-cli calendar calendars list +lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}' +``` + +### 3. Raw API Calls + +Call any Lark Open Platform endpoint directly, covering 2500+ APIs. + +```bash +lark-cli api GET /open-apis/calendar/v4/calendars +lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}' +``` + +## Advanced Usage + +### Output Formats + +```bash +--format json # Full JSON response (default) +--format pretty # Human-friendly formatted output +--format table # Readable table +--format ndjson # Newline-delimited JSON (for piping) +--format csv # Comma-separated values +``` + +### Pagination + +```bash +--page-all # Auto-paginate through all pages +--page-limit 5 # Max 5 pages +--page-delay 500 # 500ms between page requests +``` + +### Dry Run + +For commands that may have side effects, preview the request with --dry-run first: + +```bash +lark-cli im +messages-send --chat-id oc_xxx --text "hello" --dry-run +``` + +### Schema Introspection + +Use schema to inspect any API method's parameters, request body, response structure, supported identities, and scopes: + +```bash +lark-cli schema +lark-cli schema calendar.events.instance_view +lark-cli schema im.messages.delete +``` + +## Security & Risk Warnings (Read Before Use) + +This tool can be invoked by AI Agents to automate operations on the Lark/Feishu Open Platform, and carries inherent risks such as model hallucinations, unpredictable execution, and prompt injection. After you authorize Lark/Feishu permissions, the AI Agent will act under your user identity within the authorized scope, which may lead to high-risk consequences such as leakage of sensitive data or unauthorized operations. Please use with caution. + +To reduce these risks, the tool enables default security protections at multiple layers. However, these risks still exist. We strongly recommend that you do not proactively modify any default security settings; once relevant restrictions are relaxed, the risks will increase significantly, and you will bear the consequences. + +We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage. + +Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities. + +## Contributing + +Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls). + +For major changes, we recommend discussing with us first via an Issue. + +## License + +This project is licensed under the **MIT License**. +When running, it calls Lark/Feishu Open Platform APIs. To use these APIs, you must comply with the following agreements and privacy policies: + +- [Feishu User Terms of Service](https://www.feishu.cn/terms) +- [Feishu Privacy Policy](https://www.feishu.cn/privacy) +- [Feishu Open Platform App Service Provider Security Management Specifications](https://open.feishu.cn/document/uAjLw4CM/uMzNwEjLzcDMx4yM3ATM/management-practice/app-service-provider-security-management-specifications) +- [Lark User Terms of Service](https://www.larksuite.com/user-terms-of-service) +- [Lark Privacy Policy](https://www.larksuite.com/privacy-policy) diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 00000000..52a78c3b --- /dev/null +++ b/README.zh.md @@ -0,0 +1,269 @@ +# lark-cli + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-blue.svg)](https://go.dev/) + +[中文版](./README.zh.md) | [English](./README.md) + +飞书/Lark 开放平台命令行工具 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 19 个 AI Agent [Skills](./skills/)。 + +[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献) + +## 为什么选 lark-cli? + +- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书 +- **覆盖面广** — 11 大业务域、200+ 精选命令、 19 个 AI Agent [Skills](./skills/) +- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率 +- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用 +- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步 +- **安全可控** — 输入防注入、终端输出净化、OS 原生密钥链存储凭证 +- **三层调用架构** — 快捷命令(人机友好)→ API 命令(平台同步)→ 通用调用(全 API 覆盖),按需选择粒度 + +## 功能 + +| 类别 | 能力 | +| ------------- | --------------------------------------------------------------------------- | +| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 | +| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 | +| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 | +| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 | +| 📊 多维表格 | 创建和管理多维表格、字段、记录、视图、仪表盘,数据聚合分析 | +| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 | +| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 | +| 📚 知识库 | 创建和管理知识空间、节点和文档 | +| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 | +| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 | +| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 | + +## 安装与快速开始 + +### 环境要求 + +开始之前,请确保具备以下条件: + +- Node.js(`npm`/`npx`) +- Go `v1.23`+ 和 Python 3(仅源码构建需要) + +### 快速开始(人类用户) + +> **Tip:** 如果你拥有 AI Agent,可以直接把本 README 丢给它,让 AI 帮你完成安装和配置 — 跳转到[快速开始(AI Agent)](#快速开始ai-agent)查看。 + +#### 安装 CLI + +**从 npm 安装(推荐):** + +```bash +npm install -g @larksuite/cli +``` + +**从源码安装:** + +```bash +make install +``` + +#### 安装 AI Agent Skills + +[Skills](./skills/) 是结构化的指令文档,使 AI Agent 能够使用本 CLI: + +```bash +# 安装所有 skills 到当前目录 +npx skills add larksuite/cli -y + +# 安装所有 skills 到全局 +npx skills add larksuite/cli -y -g +``` + +#### 配置与使用 + +```bash +# 1. 配置应用凭证(仅需一次,交互式引导完成) +lark-cli config init + +# 2. 登录授权(--recommend 自动选择常用权限) +lark-cli auth login --recommend + +# 3. 开始使用 +lark-cli calendar +agenda +``` + +### 快速开始(AI Agent) + +> 以下步骤面向 AI Agent,部分步骤需要用户在浏览器中配合完成。 + +```bash +# 1. 安装 CLI +npm install -g @larksuite/cli + +# 2. 安装 Skills(使 AI Agent 能够使用本 CLI) +npx skills add larksuite/cli --all -y + +# 3. 配置应用凭证 +# 重要:在后台运行此命令,命令会输出一个授权链接,提取该链接并发送给用户,用户在浏览器中完成配置后命令会自动退出。 +lark-cli config init --new + +# 4. 登录 +# 同上,后台运行,提取授权链接发给用户 +lark-cli auth login --recommend + +# 5. 验证 +lark-cli auth status +``` + + +## Agent Skills + +| Skill | 说明 | +| --------------------------------- | ----------------------------------------------------------------------------- | +| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) | +| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 | +| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 | +| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) | +| `lark-drive` | 上传、下载文件,管理权限与评论 | +| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 | +| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 | +| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 | +| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 | +| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 | +| `lark-wiki` | 知识空间、节点、文档 | +| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 | +| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) | +| `lark-whiteboard` | 画板/图表 DSL 渲染 | +| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) | +| `lark-openapi-explorer` | 从官方文档探索底层 API | +| `lark-skill-maker` | 自定义 skill 创建框架 | +| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 | +| `lark-workflow-standup-report` | 工作流:日程待办摘要 | + +## 认证 + +| 命令 | 说明 | +| --------------- | -------------------------------------------------- | +| `auth login` | OAuth 登录,支持交互式选择或命令行参数指定 scope | +| `auth logout` | 登出并删除已存储的凭证 | +| `auth status` | 查看当前登录状态和已授权的 scope | +| `auth check` | 校验指定 scope(exit 0 = 有权限,1 = 缺失) | +| `auth scopes` | 列出应用的所有可用 scope | +| `auth list` | 列出所有已认证的用户 | + +```bash +# 交互式登录(TUI 引导选择业务域和权限级别) +lark-cli auth login + +# 按域筛选 +lark-cli auth login --domain calendar,task + +# 推荐的自动审批 scopes +lark-cli auth login --recommend + +# 精确 scope +lark-cli auth login --scope "calendar:calendar:readonly" + +# Agent 模式:立即返回验证 URL,不阻塞 +lark-cli auth login --domain calendar --no-wait +# 稍后恢复轮询 +lark-cli auth login --device-code + +# 身份切换:以用户或机器人身份执行命令 +lark-cli calendar +agenda --as user +lark-cli im +messages-send --as bot --chat-id "oc_xxx" --text "Hello" +``` + +## 三层命令调用 + +CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义的全部场景: + +### 1. 快捷命令(Shortcuts) + +以 `+` 为前缀,对人类与 AI 友好化封装,内置智能默认值、表格输出和 dry-run 预览。 + +```bash +lark-cli calendar +agenda +lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello" +lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能" +``` + +运行 `lark-cli --help` 查看所有快捷命令。 + +### 2. API 命令 + +从飞书 OAPI 元数据自动生成,经过评测与准入筛选,100+ 精选命令与平台端点一一对应。 + +```bash +lark-cli calendar calendars list +lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}' +``` + +### 3. 通用 API 调用 + +直接调用任意飞书开放平台端点,覆盖 2500+ API。 + +```bash +lark-cli api GET /open-apis/calendar/v4/calendars +lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}' +``` + +## 进阶用法 + +### 输出格式 + +```bash +--format json # 完整 JSON 响应(默认) +--format pretty # 人性化格式输出 +--format table # 易读表格 +--format ndjson # 换行分隔 JSON(适合管道处理) +--format csv # 逗号分隔值 +``` + +### 分页 + +```bash +--page-all # 自动翻页获取所有数据 +--page-limit 5 # 最多获取 5 页 +--page-delay 500 # 每页请求间隔 500ms +``` + +### Dry Run + +对可能产生副作用的命令,建议先用 --dry-run 预览请求: + +```bash +lark-cli im +messages-send --chat-id oc_xxx --text "hello" --dry-run +``` + +### Schema 自省 + +使用 schema 查看任意 API 方法的参数、请求体、响应结构、支持身份和 scopes: + +```bash +lark-cli schema +lark-cli schema calendar.events.instance_view +lark-cli schema im.messages.delete +``` + +## 安全与风险提示(使用前必读) + +本工具可供 AI Agent 调用以自动化操作飞书/Lark 开放平台,存在模型幻觉、执行不可控、提示词注入等固有风险;授权飞书权限后,AI Agent 将以您的用户身份在授权范围内执行操作,可能导致敏感数据泄露、越权操作等高风险后果,请您谨慎操作和使用。 + +为降低上述风险,工具已在多个层面启用默认安全保护,但上述风险仍然存在。我们强烈建议不要主动修改任何默认安全配置;一旦放开相关限制,上述风险将显著提高,由此产生的后果需由您自行承担。 + +我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。 + +请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。 + +## 贡献 + +欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。 + +对于较大的改动,建议先通过 Issue 与我们讨论。 + +## 许可证 + +本项目基于 **MIT 许可证** 开源。 +该软件运行时会调用 Lark/飞书开放平台的 API,使用这些 API 需要遵守如下协议和隐私政策: + +- [飞书用户服务协议](https://www.feishu.cn/terms) +- [飞书隐私政策](https://www.feishu.cn/privacy) +- [飞书开放平台独立软件服务商安全管理运营规范](https://open.feishu.cn/document/uAjLw4CM/uMzNwEjLzcDMx4yM3ATM/management-practice/app-service-provider-security-management-specifications) +- [Lark User Terms of Service](https://www.larksuite.com/user-terms-of-service) +- [Lark Privacy Policy](https://www.larksuite.com/privacy-policy) diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..0f0b8463 --- /dev/null +++ b/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +set -euo pipefail +cd "$(dirname "$0")" +python3 scripts/fetch_meta.py +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) +go build -ldflags "-s -w -X github.com/larksuite/cli/internal/build.Version=${VERSION} -X github.com/larksuite/cli/internal/build.Date=$(date +%Y-%m-%d)" -o lark-cli . +echo "OK: ./lark-cli (${VERSION})" diff --git a/cmd/api/api.go b/cmd/api/api.go new file mode 100644 index 00000000..d2ec7098 --- /dev/null +++ b/cmd/api/api.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "regexp" + "strings" + + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/spf13/cobra" +) + +// APIOptions holds all inputs for the api command. +type APIOptions struct { + Factory *cmdutil.Factory + Cmd *cobra.Command + Ctx context.Context + + // Positional args + Method string + Path string + + // Flags + Params string + Data string + As core.Identity + Output string + PageAll bool + PageSize int + PageLimit int + PageDelay int + Format string + DryRun bool +} + +func parseJsonOpt(input, label string) (map[string]interface{}, error) { + if input == "" { + return nil, nil + } + var result map[string]interface{} + if err := json.Unmarshal([]byte(input), &result); err != nil { + return nil, output.ErrValidation("%s invalid format, expected JSON object", label) + } + return result, nil +} + +var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`) + +func normalisePath(raw string) string { + if matches := urlPrefixRe.FindStringSubmatch(raw); len(matches) > 1 { + raw = matches[1] + } else if !strings.HasPrefix(raw, "/open-apis/") { + raw = "/open-apis/" + strings.TrimPrefix(raw, "/") + } + return validate.StripQueryFragment(raw) +} + +// NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook). +func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command { + opts := &APIOptions{Factory: f} + var asStr string + + cmd := &cobra.Command{ + Use: "api ", + Short: "Generic Lark API requests", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Method = strings.ToUpper(args[0]) + opts.Path = args[1] + opts.Cmd = cmd + opts.Ctx = cmd.Context() + opts.As = core.Identity(asStr) + if runF != nil { + return runF(opts) + } + return apiRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON") + cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON") + cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)") + cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") + cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") + cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)") + cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") + cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") + cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") + + cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + } + _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp + }) + _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp + }) + + return cmd +} + +// buildAPIRequest validates flags and builds a RawApiRequest. +func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) { + params, err := parseJsonOpt(opts.Params, "--params") + if err != nil { + return client.RawApiRequest{}, err + } + if params == nil { + params = map[string]interface{}{} + } + var data interface{} + if opts.Data != "" { + data, err = parseJsonOpt(opts.Data, "--data") + if err != nil { + return client.RawApiRequest{}, err + } + } + if opts.PageSize > 0 { + params["page_size"] = opts.PageSize + } + + request := client.RawApiRequest{ + Method: opts.Method, + URL: normalisePath(opts.Path), + Params: params, + Data: data, + As: opts.As, + } + // WithFileDownload tells the SDK to skip CodeError parsing on 200 OK. + if opts.Output != "" { + request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload()) + } + return request, nil +} + +func apiRun(opts *APIOptions) error { + f := opts.Factory + opts.As = f.ResolveAs(opts.Cmd, opts.As) + + if opts.PageAll && opts.Output != "" { + return output.ErrValidation("--output and --page-all are mutually exclusive") + } + + request, err := buildAPIRequest(opts) + if err != nil { + return err + } + + config, err := f.ResolveConfig(opts.As) + if err != nil { + return err + } + + if opts.DryRun { + return apiDryRun(f, request, config, opts.Format) + } + // Identity info is now included in the JSON envelope; skip stderr printing. + // cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected) + + ac, err := f.NewAPIClientWithConfig(config) + if err != nil { + return err + } + + out := f.IOStreams.Out + format, formatOK := output.ParseFormat(opts.Format) + if !formatOK { + fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format) + } + + if opts.PageAll { + return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut, + client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}) + } + + resp, err := ac.DoAPI(opts.Ctx, request) + if err != nil { + return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) + } + err = client.HandleResponse(resp, client.ResponseOptions{ + OutputPath: opts.Output, + Format: format, + Out: out, + ErrOut: f.IOStreams.ErrOut, + }) + // MarkRaw tells root error handler that the API response was already written + // to stdout, so it should skip the stderr error envelope. Only apply when + // HandleResponse actually wrote output (i.e. returned a business/API error + // after printing JSON to stdout). Non-JSON HTTP errors (e.g. 404 text/plain) + // produce no stdout output and need the envelope. + if err != nil && client.IsJSONContentType(resp.Header.Get("Content-Type")) { + return output.MarkRaw(err) + } + return err +} + +func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error { + return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format) +} + +func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error { + switch format { + case output.FormatNDJSON, output.FormatTable, output.FormatCSV: + pf := output.NewPaginatedFormatter(out, format) + result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) { + pf.FormatPage(items) + }, pagOpts) + if err != nil { + return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) + } + if apiErr := client.CheckLarkResponse(result); apiErr != nil { + output.FormatValue(out, result, output.FormatJSON) + return output.MarkRaw(apiErr) + } + if !hasItems { + fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format) + output.FormatValue(out, result, output.FormatJSON) + } + return nil + default: + result, err := ac.PaginateAll(ctx, request, pagOpts) + if err != nil { + return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) + } + if apiErr := client.CheckLarkResponse(result); apiErr != nil { + output.FormatValue(out, result, output.FormatJSON) + return output.MarkRaw(apiErr) + } + output.FormatValue(out, result, format) + return nil + } +} diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go new file mode 100644 index 00000000..362a6c02 --- /dev/null +++ b/cmd/api/api_test.go @@ -0,0 +1,558 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package api + +import ( + "errors" + "sort" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +func TestApiCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Method != "GET" { + t.Errorf("expected method GET, got %s", gotOpts.Method) + } + if gotOpts.Path != "/open-apis/test" { + t.Errorf("expected path /open-apis/test, got %s", gotOpts.Path) + } + if gotOpts.As != core.AsBot { + t.Errorf("expected as=bot, got %s", gotOpts.As) + } + if !gotOpts.DryRun { + t.Error("expected DryRun=true") + } +} + +func TestApiCmd_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "Dry Run") { + t.Error("expected dry run output") + } + if !strings.Contains(output, "/open-apis/test") { + t.Error("expected path in dry run output") + } +} + +func TestApiCmd_BotMode(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + // Register tenant_access_token stub + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "tenant_access_token": "t-test-token", + "expire": 7200, + }, + }) + // Register API endpoint stub + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}}, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "success") { + t.Error("expected 'success' in output") + } +} + +func TestApiCmd_MissingArgs(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET"}) // missing path + err := cmd.Execute() + if err == nil { + t.Error("expected error for missing args") + } +} + +func TestApiCmd_InvalidParamsJSON(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"}) + err := cmd.Execute() + if err == nil { + t.Error("expected validation error for invalid JSON") + } +} + +func TestApiValidArgsFunction(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, nil) + fn := cmd.ValidArgsFunction + + tests := []struct { + name string + args []string + toComplete string + wantComps []string + wantDir cobra.ShellCompDirective + }{ + { + name: "no args returns HTTP methods", + args: []string{}, + toComplete: "", + wantComps: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, + wantDir: cobra.ShellCompDirectiveNoFileComp, + }, + { + name: "one arg returns nil with NoFileComp", + args: []string{"GET"}, + toComplete: "", + wantComps: nil, + wantDir: cobra.ShellCompDirectiveNoFileComp, + }, + { + name: "two args returns nil with NoFileComp", + args: []string{"GET", "/path"}, + toComplete: "", + wantComps: nil, + wantDir: cobra.ShellCompDirectiveNoFileComp, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + comps, dir := fn(cmd, tt.args, tt.toComplete) + if dir != tt.wantDir { + t.Errorf("directive = %d, want %d", dir, tt.wantDir) + } + if tt.wantComps == nil { + if comps != nil { + t.Errorf("completions = %v, want nil", comps) + } + return + } + sort.Strings(comps) + sort.Strings(tt.wantComps) + if len(comps) != len(tt.wantComps) { + t.Errorf("completions = %v, want %v", comps, tt.wantComps) + return + } + for i := range comps { + if comps[i] != tt.wantComps[i] { + t.Errorf("completions = %v, want %v", comps, tt.wantComps) + break + } + } + }) + } +} + +func TestApiCmd_PageLimitDefault(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"GET", "/open-apis/test"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.PageLimit != 10 { + t.Errorf("expected default PageLimit=10, got %d", gotOpts.PageLimit) + } +} + +func TestApiCmd_OutputAndPageAllConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return apiRun(opts) + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--page-all", "--output", "file.bin"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --output + --page-all conflict") + } + if gotOpts != nil && !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-bin", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/drive/v1/files/xxx/download", + RawBody: []byte("fake-binary-content"), + ContentType: "application/octet-stream", + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stderr.String(), "binary response detected") { + t.Error("expected binary response hint in stderr") + } + if !strings.Contains(stdout.String(), "saved_path") { + t.Error("expected saved_path in output") + } +} + +func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu, + }) + + // Register tenant_access_token stub + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-pa1", "expire": 7200, + }, + }) + // Register a non-batch API that returns scalar data (no array field) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/contact/v3/users/u123", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "user_id": "u123", + "name": "Test User", + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should print fallback warning to stderr + if !strings.Contains(stderr.String(), "warning: this API does not return a list") { + t.Error("expected fallback warning in stderr") + } + if !strings.Contains(stderr.String(), "falling back to json") { + t.Error("expected 'falling back to json' in stderr") + } + // Should output JSON result to stdout + if !strings.Contains(stdout.String(), "u123") { + t.Error("expected user_id in JSON output") + } +} + +func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-err", "expire": 7200, + }, + }) + // Non-batch API that returns a business error (code != 0) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/im/v1/chats/oc_xxx/announcement", + Body: map[string]interface{}{ + "code": 230001, "msg": "no permission", + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"}) + err := cmd.Execute() + // Should return an error + if err == nil { + t.Fatal("expected an error for non-zero code") + } + // Should still output the response body so user can see the error details + if !strings.Contains(stdout.String(), "230001") { + t.Errorf("expected error response in stdout, got: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), "no permission") { + t.Errorf("expected error message in stdout, got: %s", stdout.String()) + } +} + +func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu, + }) + + // Register tenant_access_token stub (unique app credentials => new token request) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-pa2", "expire": 7200, + }, + }) + // Register a batch API that returns an array field + reg.Register(&httpmock.Stub{ + URL: "/open-apis/contact/v3/users", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, + "has_more": false, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should NOT print fallback warning + if strings.Contains(stderr.String(), "warning: this API does not return a list") { + t.Error("expected no fallback warning for batch API") + } + // Should stream ndjson items + if !strings.Contains(stdout.String(), `"id"`) { + t.Error("expected streamed items in output") + } +} + +func TestNormalisePath_StripsQueryAndFragment(t *testing.T) { + for _, tt := range []struct { + name string + raw string + want string + }{ + {"plain path", "/open-apis/test", "/open-apis/test"}, + {"with query", "/open-apis/test?admin=true", "/open-apis/test"}, + {"with fragment", "/open-apis/test#section", "/open-apis/test"}, + {"with both", "/open-apis/test?a=1#frag", "/open-apis/test"}, + {"full URL with query", "https://open.feishu.cn/open-apis/foo?bar=1", "/open-apis/foo"}, + {"short path with query", "contact/v3/users?page_size=50", "/open-apis/contact/v3/users"}, + } { + t.Run(tt.name, func(t *testing.T) { + got := normalisePath(tt.raw) + if got != tt.want { + t.Errorf("normalisePath(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestApiCmd_APIError_IsRaw(t *testing.T) { + f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-raw", "expire": 7200, + }, + }) + // Return a permission error from the API + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/perm", + Body: map[string]interface{}{ + "code": 99991672, + "msg": "scope not enabled for this app", + "error": map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "calendar:calendar:readonly"}, + }, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test/perm", "--as", "bot"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for permission denied API response") + } + + // Error should be marked Raw + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if !exitErr.Raw { + t.Error("expected API error from api command to be marked Raw") + } + + // stderr should NOT contain an error envelope (identity line is OK) + if strings.Contains(stderr.String(), `"ok"`) { + t.Error("expected no JSON error envelope on stderr for Raw API error") + } +} + +func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-origmsg", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/origmsg", + Body: map[string]interface{}{ + "code": 99991672, + "msg": "scope not enabled for this app", + "error": map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "im:message:readonly"}, + }, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test/origmsg", "--as", "bot"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + // The message should NOT have been enriched (no "App scope not enabled" replacement) + if strings.Contains(exitErr.Error(), "App scope not enabled") { + t.Error("expected original message, not enriched message") + } + // Detail should still contain the raw API error detail + if exitErr.Detail == nil { + t.Fatal("expected non-nil Detail") + } + if exitErr.Detail.Detail == nil { + t.Error("expected raw Detail.Detail to be preserved (not cleared by enrichment)") + } +} + +func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-rawpage", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/rawpage", + Body: map[string]interface{}{ + "code": 99991672, + "msg": "scope not enabled", + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test/rawpage", "--as", "bot", "--page-all"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if !exitErr.Raw { + t.Error("expected paginated API error to be marked Raw") + } +} + +func TestApiCmd_MethodUppercase(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"post", "/test"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Method != "POST" { + t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method) + } +} diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go new file mode 100644 index 00000000..f8a4f1c8 --- /dev/null +++ b/cmd/auth/auth.go @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" +) + +// NewCmdAuth creates the auth command with subcommands. +func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "OAuth credentials and authorization management", + } + cmdutil.DisableAuthCheck(cmd) + + cmd.AddCommand(NewCmdAuthLogin(f, nil)) + cmd.AddCommand(NewCmdAuthLogout(f, nil)) + cmd.AddCommand(NewCmdAuthStatus(f, nil)) + cmd.AddCommand(NewCmdAuthScopes(f, nil)) + cmd.AddCommand(NewCmdAuthList(f, nil)) + cmd.AddCommand(NewCmdAuthCheck(f, nil)) + return cmd +} + +// userInfoResponse is the API response for /open-apis/authen/v1/user_info. +type userInfoResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + OpenID string `json:"open_id"` + Name string `json:"name"` + } `json:"data"` +} + +// getUserInfo fetches the current user's OpenID and name using the given access token. +func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, name string, err error) { + apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/authen/v1/user_info", + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}, + }, larkcore.WithUserAccessToken(accessToken)) + if err != nil { + return "", "", err + } + + var resp userInfoResponse + if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { + return "", "", fmt.Errorf("failed to parse user info: %v", err) + } + if resp.Code != 0 { + return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg) + } + if resp.Data.OpenID == "" { + return "", "", fmt.Errorf("failed to get user info: missing open_id in response") + } + + name = resp.Data.Name + if name == "" { + name = "(unknown)" + } + return resp.Data.OpenID, name, nil +} + +// appInfo contains application information (owner, scopes). +type appInfo struct { + OwnerOpenId string + UserScopes []string +} + +// appInfoResponse is the API response for /open-apis/application/v6/applications/:app_id. +type appInfoResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + App struct { + Owner struct { + OwnerID string `json:"owner_id"` + } `json:"owner"` + CreatorID string `json:"creator_id"` + Scopes []struct { + Scope string `json:"scope"` + TokenTypes []string `json:"token_types"` + } `json:"scopes"` + } `json:"app"` + } `json:"data"` +} + +// getAppInfo queries app info from the Lark API. +func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) { + sdk, err := f.LarkClient() + if err != nil { + return nil, err + } + + queryParams := make(larkcore.QueryParams) + queryParams.Set("lang", "zh_cn") + + apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/application/v6/applications/" + appId, + QueryParams: queryParams, + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}, + }) + if err != nil { + return nil, err + } + + var resp appInfoResponse + if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + if resp.Code != 0 { + return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg) + } + + app := resp.Data.App + ownerOpenId := app.Owner.OwnerID + if ownerOpenId == "" { + ownerOpenId = app.CreatorID + } + + var userScopes []string + for _, s := range app.Scopes { + if s.Scope == "" || !slices.Contains(s.TokenTypes, "user") { + continue + } + userScopes = append(userScopes, s.Scope) + } + + return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil +} diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go new file mode 100644 index 00000000..15bd828f --- /dev/null +++ b/cmd/auth/auth_test.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "sort" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/registry" +) + +func TestAuthLoginCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *LoginOptions + cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Scope != "calendar:calendar:read" { + t.Errorf("expected scope calendar:calendar:read, got %s", gotOpts.Scope) + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } +} + +func TestAuthCheckCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *CheckOptions + cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--scope", "calendar:calendar:read drive:drive:read"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Scope != "calendar:calendar:read drive:drive:read" { + t.Errorf("expected scope string, got %s", gotOpts.Scope) + } +} + +func TestAuthLogoutCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *LogoutOptions + cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } +} + +func TestAuthListCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ListOptions + cmd := NewCmdAuthList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } +} + +func TestAuthStatusCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *StatusOptions + cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error { + gotOpts = opts + return nil + }) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } +} + +func TestAuthStatusCmd_VerifyFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *StatusOptions + cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--verify"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected opts to be set") + } + if !gotOpts.Verify { + t.Error("expected Verify=true when --verify flag is passed") + } +} + +func TestDomainFlagCompletion(t *testing.T) { + allDomains := registry.ListFromMetaProjects() + + tests := []struct { + name string + toComplete string + wantContains []string + wantExclude []string + }{ + { + name: "empty returns all domains", + toComplete: "", + wantContains: allDomains, + }, + { + name: "partial match", + toComplete: "cal", + wantContains: []string{"calendar"}, + wantExclude: []string{"bitable", "drive", "task"}, + }, + { + name: "comma prefix completes second value", + toComplete: "calendar,", + wantContains: func() []string { + var out []string + for _, d := range allDomains { + out = append(out, "calendar,"+d) + } + return out + }(), + }, + { + name: "comma with partial second value", + toComplete: "calendar,ta", + wantContains: []string{"calendar,task"}, + wantExclude: []string{"calendar,bitable", "calendar,drive"}, + }, + { + name: "no match returns empty", + toComplete: "xxx", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + comps := completeDomain(tt.toComplete) + sort.Strings(comps) + + for _, want := range tt.wantContains { + found := false + for _, c := range comps { + if c == want { + found = true + break + } + } + if !found { + t.Errorf("completions %v missing expected %q", comps, want) + } + } + + for _, exclude := range tt.wantExclude { + for _, c := range comps { + if c == exclude { + t.Errorf("completions %v should not contain %q", comps, exclude) + } + } + } + + // Verify no completion contains trailing comma artifacts + for _, c := range comps { + if strings.HasSuffix(c, ",") { + t.Errorf("completion %q should not end with comma", c) + } + } + }) + } +} + +func TestAuthScopesCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *ScopesOptions + cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--format", "json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Format != "json" { + t.Errorf("expected format json, got %s", gotOpts.Format) + } +} diff --git a/cmd/auth/check.go b/cmd/auth/check.go new file mode 100644 index 00000000..5f0bd0f4 --- /dev/null +++ b/cmd/auth/check.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +// CheckOptions holds all inputs for auth check. +type CheckOptions struct { + Factory *cmdutil.Factory + Scope string +} + +// NewCmdAuthCheck creates the auth check subcommand. +func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command { + opts := &CheckOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "check", + Short: "Check if current token has specified scopes", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return authCheckRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)") + cmd.MarkFlagRequired("scope") + + return cmd +} + +func authCheckRun(opts *CheckOptions) error { + f := opts.Factory + + required := strings.Fields(opts.Scope) + if len(required) == 0 { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}}) + return nil + } + + config, err := f.Config() + if err != nil { + return err + } + if config.UserOpenId == "" { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "not_logged_in", "missing": required}) + return output.ErrBare(1) + } + + stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId) + if stored == nil { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "no_token", "missing": required}) + return output.ErrBare(1) + } + + missing := larkauth.MissingScopes(stored.Scope, required) + missingSet := make(map[string]bool, len(missing)) + for _, s := range missing { + missingSet[s] = true + } + var granted []string + for _, s := range required { + if !missingSet[s] { + granted = append(granted, s) + } + } + + ok := len(missing) == 0 + result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing} + if len(missing) > 0 { + result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " ")) + } + output.PrintJson(f.IOStreams.Out, result) + if !ok { + return output.ErrBare(1) + } + return nil +} diff --git a/cmd/auth/list.go b/cmd/auth/list.go new file mode 100644 index 00000000..24086959 --- /dev/null +++ b/cmd/auth/list.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// ListOptions holds all inputs for auth list. +type ListOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdAuthList creates the auth list subcommand. +func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "list", + Short: "List all logged-in users", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return authListRun(opts) + }, + } + + return cmd +} + +func authListRun(opts *ListOptions) error { + f := opts.Factory + + multi, _ := core.LoadMultiAppConfig() + if multi == nil || len(multi.Apps) == 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.") + return nil + } + + app := multi.Apps[0] + if len(app.Users) == 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.") + return nil + } + + var items []map[string]interface{} + for _, u := range app.Users { + stored := larkauth.GetStoredToken(app.AppId, u.UserOpenId) + status := "no_token" + if stored != nil { + status = larkauth.TokenStatus(stored) + } + items = append(items, map[string]interface{}{ + "userName": u.UserName, + "userOpenId": u.UserOpenId, + "appId": app.AppId, + "tokenStatus": status, + }) + } + output.PrintJson(f.IOStreams.Out, items) + return nil +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go new file mode 100644 index 00000000..572965d3 --- /dev/null +++ b/cmd/auth/login.go @@ -0,0 +1,475 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" + "github.com/larksuite/cli/shortcuts/common" +) + +// LoginOptions holds all inputs for auth login. +type LoginOptions struct { + Factory *cmdutil.Factory + Ctx context.Context + JSON bool + Scope string + Recommend bool + Domains []string + NoWait bool + DeviceCode string +} + +// NewCmdAuthLogin creates the auth login subcommand. +func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { + opts := &LoginOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "login", + Short: "Device Flow authorization login", + Long: `Device Flow authorization login. + +For AI agents: this command blocks until the user completes authorization in the +browser. Run it in the background and retrieve the verification URL from its output.`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Ctx = cmd.Context() + if runF != nil { + return runF(opts) + } + return authLoginRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)") + cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes") + available := sortedKnownDomains() + cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil, + fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", "))) + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") + cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete") + cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call") + + _ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp + }) + + return cmd +} + +// completeDomain returns completions for comma-separated domain values. +func completeDomain(toComplete string) []string { + allDomains := registry.ListFromMetaProjects() + parts := strings.Split(toComplete, ",") + prefix := parts[len(parts)-1] + base := strings.Join(parts[:len(parts)-1], ",") + + var completions []string + for _, d := range allDomains { + if strings.HasPrefix(d, prefix) { + if base == "" { + completions = append(completions, d) + } else { + completions = append(completions, base+","+d) + } + } + } + return completions +} + +func authLoginRun(opts *LoginOptions) error { + f := opts.Factory + + config, err := f.Config() + if err != nil { + return err + } + + // Determine UI language from saved config + lang := "zh" + if multi, _ := core.LoadMultiAppConfig(); multi != nil && len(multi.Apps) > 0 { + lang = multi.Apps[0].Lang + } + msg := getLoginMsg(lang) + + log := func(format string, a ...interface{}) { + if !opts.JSON { + fmt.Fprintf(f.IOStreams.ErrOut, format+"\n", a...) + } + } + + // --device-code: resume polling from a previous --no-wait call + if opts.DeviceCode != "" { + return authLoginPollDeviceCode(opts, config, msg, log) + } + + selectedDomains := opts.Domains + scopeLevel := "" // "common" or "all" (from interactive mode) + + // Expand --domain all to all available domains (from_meta projects + shortcut services) + for _, d := range selectedDomains { + if strings.EqualFold(d, "all") { + domainSet := make(map[string]bool) + for _, p := range registry.ListFromMetaProjects() { + domainSet[p] = true + } + for _, sc := range shortcuts.AllShortcuts() { + domainSet[sc.Service] = true + } + selectedDomains = make([]string, 0, len(domainSet)) + for d := range domainSet { + selectedDomains = append(selectedDomains, d) + } + sort.Strings(selectedDomains) + break + } + } + + // Validate domain names and suggest corrections for unknown ones + if len(selectedDomains) > 0 { + knownDomains := allKnownDomains() + for _, d := range selectedDomains { + if !knownDomains[d] { + if suggestion := suggestDomain(d, knownDomains); suggestion != "" { + return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion) + } + available := make([]string, 0, len(knownDomains)) + for k := range knownDomains { + available = append(available, k) + } + sort.Strings(available) + return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", ")) + } + } + } + + hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0 + + if !hasAnyOption { + if !opts.JSON && f.IOStreams.IsTerminal { + result, err := runInteractiveLogin(f.IOStreams, lang, msg) + if err != nil { + return err + } + if result == nil { + return output.ErrValidation("no login options selected") + } + selectedDomains = result.Domains + scopeLevel = result.ScopeLevel + } else { + log(msg.HintHeader) + log("Common options:") + log(msg.HintCommon1) + log(msg.HintCommon2) + log(msg.HintCommon3) + log(msg.HintCommon4) + log("") + log("View all options:") + log(msg.HintFooter) + log("") + log("Note: this command blocks until authorization is complete. Run it in the background and retrieve the verification URL from its output.") + return output.ErrValidation("please specify the scopes to authorize") + } + } + + finalScope := opts.Scope + + // Resolve scopes from domain/permission filters + if len(selectedDomains) > 0 || opts.Recommend { + if opts.Scope != "" { + return output.ErrValidation("cannot use --scope together with --domain/--recommend") + } + + var candidateScopes []string + if len(selectedDomains) > 0 { + candidateScopes = collectScopesForDomains(selectedDomains, "user") + } else { + // --recommend without --domain: all domains + candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user") + } + + // Filter to auto-approve scopes if --recommend or interactive "common" + if opts.Recommend || scopeLevel == "common" { + candidateScopes = registry.FilterAutoApproveScopes(candidateScopes) + } + + if len(candidateScopes) == 0 { + return output.ErrValidation("no matching scopes found, check domain/scope options") + } + + finalScope = strings.Join(candidateScopes, " ") + } + + // Step 1: Request device authorization + httpClient, err := f.HttpClient() + if err != nil { + return err + } + authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut) + if err != nil { + return output.ErrAuth("device authorization failed: %v", err) + } + + // --no-wait: return immediately with device code and URL + if opts.NoWait { + b, _ := json.Marshal(map[string]interface{}{ + "verification_url": authResp.VerificationUriComplete, + "device_code": authResp.DeviceCode, + "expires_in": authResp.ExpiresIn, + "hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode), + }) + fmt.Fprintln(f.IOStreams.Out, string(b)) + return nil + } + + // Step 2: Show user code and verification URL + if opts.JSON { + b, _ := json.Marshal(map[string]interface{}{ + "event": "device_authorization", + "verification_uri": authResp.VerificationUri, + "verification_uri_complete": authResp.VerificationUriComplete, + "user_code": authResp.UserCode, + "expires_in": authResp.ExpiresIn, + }) + fmt.Fprintln(f.IOStreams.Out, string(b)) + } else { + fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL) + fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete) + } + + // Step 3: Poll for token + log(msg.WaitingAuth) + result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, + authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) + + if !result.OK { + if opts.JSON { + b, _ := json.Marshal(map[string]interface{}{ + "event": "authorization_failed", + "error": result.Message, + }) + fmt.Fprintln(f.IOStreams.Out, string(b)) + return output.ErrBare(output.ExitAuth) + } + return output.ErrAuth("authorization failed: %s", result.Message) + } + + // Step 6: Get user info + log(msg.AuthSuccess) + sdk, err := f.LarkClient() + if err != nil { + return output.ErrAuth("failed to get SDK: %v", err) + } + openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) + if err != nil { + return output.ErrAuth("failed to get user info: %v", err) + } + + // Step 7: Store token + now := time.Now().UnixMilli() + storedToken := &larkauth.StoredUAToken{ + UserOpenId: openId, + AppId: config.AppID, + AccessToken: result.Token.AccessToken, + RefreshToken: result.Token.RefreshToken, + ExpiresAt: now + int64(result.Token.ExpiresIn)*1000, + RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000, + Scope: result.Token.Scope, + GrantedAt: now, + } + if err := larkauth.SetStoredToken(storedToken); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err) + } + + // Step 8: Update config — overwrite Users to single user, clean old tokens + multi, _ := core.LoadMultiAppConfig() + if multi != nil && len(multi.Apps) > 0 { + app := &multi.Apps[0] + for _, oldUser := range app.Users { + if oldUser.UserOpenId != openId { + larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId) + } + } + app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}} + if err := core.SaveMultiAppConfig(multi); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + } + + if opts.JSON { + b, _ := json.Marshal(map[string]interface{}{ + "event": "authorization_complete", + "user_open_id": openId, + "user_name": userName, + "scope": result.Token.Scope, + }) + fmt.Fprintln(f.IOStreams.Out, string(b)) + } else { + fmt.Fprintln(f.IOStreams.ErrOut) + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId)) + if result.Token.Scope != "" { + fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope) + } + } + return nil +} + +// authLoginPollDeviceCode resumes the device flow by polling with a device code +// obtained from a previous --no-wait call. +func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *loginMsg, log func(string, ...interface{})) error { + f := opts.Factory + + httpClient, err := f.HttpClient() + if err != nil { + return err + } + log(msg.WaitingAuth) + result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, + opts.DeviceCode, 5, 180, f.IOStreams.ErrOut) + + if !result.OK { + return output.ErrAuth("authorization failed: %s", result.Message) + } + if result.Token == nil { + return output.ErrAuth("authorization succeeded but no token returned") + } + + // Get user info + log(msg.AuthSuccess) + sdk, err := f.LarkClient() + if err != nil { + return output.ErrAuth("failed to get SDK: %v", err) + } + openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) + if err != nil { + return output.ErrAuth("failed to get user info: %v", err) + } + + // Store token + now := time.Now().UnixMilli() + storedToken := &larkauth.StoredUAToken{ + UserOpenId: openId, + AppId: config.AppID, + AccessToken: result.Token.AccessToken, + RefreshToken: result.Token.RefreshToken, + ExpiresAt: now + int64(result.Token.ExpiresIn)*1000, + RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000, + Scope: result.Token.Scope, + GrantedAt: now, + } + if err := larkauth.SetStoredToken(storedToken); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err) + } + + // Update config — overwrite Users to single user, clean old tokens + multi, _ := core.LoadMultiAppConfig() + if multi != nil && len(multi.Apps) > 0 { + app := &multi.Apps[0] + for _, oldUser := range app.Users { + if oldUser.UserOpenId != openId { + larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId) + } + } + app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}} + if err := core.SaveMultiAppConfig(multi); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + } + + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId)) + return nil +} + +// collectScopesForDomains collects API scopes (from from_meta projects) and +// shortcut scopes for the given domain names. +func collectScopesForDomains(domains []string, identity string) []string { + scopeSet := make(map[string]bool) + + // 1. API scopes from from_meta projects + for _, s := range registry.CollectScopesForProjects(domains, identity) { + scopeSet[s] = true + } + + // 2. Shortcut scopes matching by Service (only include shortcuts supporting the identity) + domainSet := make(map[string]bool, len(domains)) + for _, d := range domains { + domainSet[d] = true + } + for _, sc := range shortcuts.AllShortcuts() { + if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) { + for _, s := range sc.ScopesForIdentity(identity) { + scopeSet[s] = true + } + } + } + + // 3. Deduplicate and sort + result := make([]string, 0, len(scopeSet)) + for s := range scopeSet { + result = append(result, s) + } + sort.Strings(result) + return result +} + +// allKnownDomains returns all valid domain names (from_meta projects + shortcut services). +func allKnownDomains() map[string]bool { + domains := make(map[string]bool) + for _, p := range registry.ListFromMetaProjects() { + domains[p] = true + } + for _, sc := range shortcuts.AllShortcuts() { + domains[sc.Service] = true + } + return domains +} + +// sortedKnownDomains returns all valid domain names sorted alphabetically. +func sortedKnownDomains() []string { + m := allKnownDomains() + domains := make([]string, 0, len(m)) + for d := range m { + domains = append(domains, d) + } + sort.Strings(domains) + return domains +} + +// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot"). +// Empty AuthTypes defaults to ["user"]. +func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool { + authTypes := sc.AuthTypes + if len(authTypes) == 0 { + authTypes = []string{"user"} + } + for _, t := range authTypes { + if t == identity { + return true + } + } + return false +} + +// suggestDomain finds the best "did you mean" match for an unknown domain. +func suggestDomain(input string, known map[string]bool) string { + // Check common cases: prefix match or input is a substring + for k := range known { + if strings.HasPrefix(k, input) || strings.HasPrefix(input, k) { + return k + } + } + return "" +} diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go new file mode 100644 index 00000000..486d3d50 --- /dev/null +++ b/cmd/auth/login_interactive.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/huh" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" +) + +// domainMeta describes a domain for the interactive selector. +type domainMeta struct { + Name string + Title string + Description string +} + +// interactiveResult holds the user's selections from the interactive form. +type interactiveResult struct { + Domains []string + ScopeLevel string // "common" or "all" +} + +// getDomainMetadata returns metadata for all known domains, sorted by name. +func getDomainMetadata(lang string) []domainMeta { + seen := make(map[string]bool) + var domains []domainMeta + + // 1. Domains from from_meta projects + for _, project := range registry.ListFromMetaProjects() { + dm := buildDomainMeta(project, lang) + domains = append(domains, dm) + seen[project] = true + } + + // 2. Shortcut-only domains + shortcutOnlyNames := getShortcutOnlyDomainNames() + for _, name := range shortcutOnlyNames { + if !seen[name] { + dm := buildDomainMeta(name, lang) + domains = append(domains, dm) + seen[name] = true + } + } + + // 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains + shortcutOnlySet := make(map[string]bool) + for _, n := range shortcutOnlyNames { + shortcutOnlySet[n] = true + } + for _, sc := range shortcuts.AllShortcuts() { + if !seen[sc.Service] { + if shortcutOnlySet[sc.Service] { + dm := buildDomainMeta(sc.Service, lang) + domains = append(domains, dm) + } + seen[sc.Service] = true + } + } + + sort.Slice(domains, func(i, j int) bool { + return domains[i].Name < domains[j].Name + }) + return domains +} + +// buildDomainMeta constructs a domainMeta for a given service name and language. +// It reads from the service_descriptions.json config first, falling back to +// from_meta spec fields if not found. +func buildDomainMeta(name, lang string) domainMeta { + title := registry.GetServiceTitle(name, lang) + desc := registry.GetServiceDetailDescription(name, lang) + if title != "" || desc != "" { + return domainMeta{ + Name: name, + Title: title, + Description: desc, + } + } + // Fallback: read from from_meta spec (legacy) + meta := registry.LoadFromMeta(name) + dm := domainMeta{Name: name} + if meta != nil { + if t, ok := meta["title"].(string); ok { + dm.Title = t + } + if d, ok := meta["description"].(string); ok { + dm.Description = d + } + } + return dm +} + +// runInteractiveLogin shows an interactive TUI form for domain and permission selection. +func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) { + allDomains := getDomainMetadata(lang) + + // Build multi-select options + options := make([]huh.Option[string], len(allDomains)) + for i, dm := range allDomains { + var label string + switch { + case dm.Title != "" && dm.Description != "": + label = fmt.Sprintf("%-12s %s - %s", dm.Name, dm.Title, dm.Description) + case dm.Title != "": + label = fmt.Sprintf("%-12s %s", dm.Name, dm.Title) + default: + label = fmt.Sprintf("%-12s %s", dm.Name, dm.Description) + } + options[i] = huh.NewOption(label, dm.Name) + } + + var selectedDomains []string + var permLevel string + + // Phase 1a: domain selection + // Phase 1b: permission level (shown after domain selection completes) + form1 := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title(msg.SelectDomains). + Description(msg.DomainHint). + Options(options...). + Value(&selectedDomains). + Validate(func(s []string) error { + if len(s) == 0 { + return fmt.Errorf(msg.ErrNoDomain) + } + return nil + }), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title(msg.PermLevel). + Options( + huh.NewOption(msg.PermCommon, "common"), + huh.NewOption(msg.PermAll, "all"), + ). + Value(&permLevel), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form1.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + + if len(selectedDomains) == 0 { + return nil, output.ErrValidation("no domains selected") + } + + // Compute scope summary + scopes := collectScopesForDomains(selectedDomains, "user") + if permLevel == "common" { + scopes = registry.FilterAutoApproveScopes(scopes) + } + + // Print summary + permLabel := msg.PermAllLabel + if permLevel == "common" { + permLabel = msg.PermCommonLabel + } + fmt.Fprintf(ios.ErrOut, msg.Summary) + fmt.Fprintf(ios.ErrOut, msg.SummaryDomains, strings.Join(selectedDomains, ", ")) + fmt.Fprintf(ios.ErrOut, msg.SummaryPerm, permLabel) + scopePreview := strings.Join(scopes, ", ") + if len(scopePreview) > 80 { + scopePreview = strings.Join(scopes[:3], ", ") + ", ..." + } + fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview) + + // Phase 2: confirmation + var confirmed bool + form2 := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(msg.ConfirmAuth). + Value(&confirmed), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form2.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + + if !confirmed { + return nil, output.ErrBare(1) + } + + return &interactiveResult{ + Domains: selectedDomains, + ScopeLevel: permLevel, + }, nil +} diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go new file mode 100644 index 00000000..f1634967 --- /dev/null +++ b/cmd/auth/login_messages.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +type loginMsg struct { + // Interactive UI (login_interactive.go) + SelectDomains string + DomainHint string + PermLevel string + PermCommon string + PermAll string + Summary string + SummaryDomains string + SummaryPerm string + SummaryScopes string + PermAllLabel string + PermCommonLabel string + ErrNoDomain string + ConfirmAuth string + + // Non-interactive prompts (login.go) + OpenURL string + WaitingAuth string + AuthSuccess string + LoginSuccess string + GrantedScopes string + + // Non-interactive hint (no flags) + HintHeader string + HintCommon1 string + HintCommon2 string + HintCommon3 string + HintCommon4 string + HintFooter string +} + +var loginMsgZh = &loginMsg{ + SelectDomains: "选择要授权的业务域", + DomainHint: "空格=选择, 回车=确认", + PermLevel: "权限类型", + PermCommon: "常用权限", + PermAll: "全部权限", + Summary: "\n摘要:\n", + SummaryDomains: " 域: %s\n", + SummaryPerm: " 权限: %s\n", + SummaryScopes: " Scopes (%d): %s\n\n", + PermAllLabel: "全部权限", + PermCommonLabel: "常用权限", + ErrNoDomain: "请至少选择一个业务域", + ConfirmAuth: "确认授权?", + + OpenURL: "在浏览器中打开以下链接进行认证:\n\n", + WaitingAuth: "等待用户授权...", + AuthSuccess: "授权成功,正在获取用户信息...", + LoginSuccess: "登录成功! 用户: %s (%s)", + GrantedScopes: " 已授权 scopes: %s\n", + + HintHeader: "请指定要授权的权限:\n", + HintCommon1: " --recommend 授权推荐权限", + HintCommon2: " --domain all 授权所有已知域的权限", + HintCommon3: " --domain calendar,task 授权日历和任务域的权限", + HintCommon4: " --domain calendar --recommend 授权日历域的推荐权限", + HintFooter: " lark-cli auth login --help", +} + +var loginMsgEn = &loginMsg{ + SelectDomains: "Select domains to authorize", + DomainHint: "Space=toggle, Enter=confirm", + PermLevel: "Permission level", + PermCommon: "Common scopes", + PermAll: "All scopes", + Summary: "\nSummary:\n", + SummaryDomains: " Domains: %s\n", + SummaryPerm: " Level: %s\n", + SummaryScopes: " Scopes (%d): %s\n\n", + PermAllLabel: "All scopes", + PermCommonLabel: "Common scopes", + ErrNoDomain: "please select at least one domain", + ConfirmAuth: "Confirm authorization?", + + OpenURL: "Open this URL in your browser to authenticate:\n\n", + WaitingAuth: "Waiting for user authorization...", + AuthSuccess: "Authorization successful, fetching user info...", + LoginSuccess: "Login successful! User: %s (%s)", + GrantedScopes: " Granted scopes: %s\n", + + HintHeader: "Please specify the scopes to authorize:\n", + HintCommon1: " --recommend authorize recommended scopes", + HintCommon2: " --domain all authorize all known domain scopes", + HintCommon3: " --domain calendar,task authorize calendar and task scopes", + HintCommon4: " --domain calendar --recommend authorize calendar recommended scopes", + HintFooter: " lark-cli auth login --help", +} + +func getLoginMsg(lang string) *loginMsg { + if lang == "en" { + return loginMsgEn + } + return loginMsgZh +} + +// getShortcutOnlyDomainNames returns domain names that exist only as shortcuts +// (not backed by from_meta service specs). Descriptions are now centralized in +// service_descriptions.json. +func getShortcutOnlyDomainNames() []string { + return []string{"base", "contact", "docs"} +} diff --git a/cmd/auth/login_messages_test.go b/cmd/auth/login_messages_test.go new file mode 100644 index 00000000..500866de --- /dev/null +++ b/cmd/auth/login_messages_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "reflect" + "testing" +) + +func TestGetLoginMsg_Zh(t *testing.T) { + msg := getLoginMsg("zh") + if msg != loginMsgZh { + t.Error("expected zh message set") + } + if msg.SelectDomains != "选择要授权的业务域" { + t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains) + } +} + +func TestGetLoginMsg_En(t *testing.T) { + msg := getLoginMsg("en") + if msg != loginMsgEn { + t.Error("expected en message set") + } + if msg.SelectDomains != "Select domains to authorize" { + t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains) + } +} + +func TestGetLoginMsg_DefaultsToZh(t *testing.T) { + for _, lang := range []string{"", "fr", "ja", "unknown"} { + msg := getLoginMsg(lang) + if msg != loginMsgZh { + t.Errorf("getLoginMsg(%q) should default to zh", lang) + } + } +} + +func TestLoginMsgZh_AllFieldsNonEmpty(t *testing.T) { + assertLoginMsgAllFieldsNonEmpty(t, loginMsgZh, "zh") +} + +func TestLoginMsgEn_AllFieldsNonEmpty(t *testing.T) { + assertLoginMsgAllFieldsNonEmpty(t, loginMsgEn, "en") +} + +func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string) { + t.Helper() + v := reflect.ValueOf(*msg) + typ := v.Type() + for i := 0; i < v.NumField(); i++ { + field := typ.Field(i) + val := v.Field(i).String() + if val == "" { + t.Errorf("%s.%s is empty", label, field.Name) + } + } +} + +func TestLoginMsg_FormatStrings(t *testing.T) { + for _, lang := range []string{"zh", "en"} { + msg := getLoginMsg(lang) + + // LoginSuccess should contain two %s placeholders (userName, openId) + got := fmt.Sprintf(msg.LoginSuccess, "testuser", "ou_123") + if got == msg.LoginSuccess { + t.Errorf("%s LoginSuccess has no format verb", lang) + } + + // GrantedScopes should contain %s + got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2") + if got == msg.GrantedScopes { + t.Errorf("%s GrantedScopes has no format verb", lang) + } + + // SummaryDomains should contain %s + got = fmt.Sprintf(msg.SummaryDomains, "calendar, task") + if got == msg.SummaryDomains { + t.Errorf("%s SummaryDomains has no format verb", lang) + } + + // SummaryPerm should contain %s + got = fmt.Sprintf(msg.SummaryPerm, "all") + if got == msg.SummaryPerm { + t.Errorf("%s SummaryPerm has no format verb", lang) + } + + // SummaryScopes should contain %d and %s + got = fmt.Sprintf(msg.SummaryScopes, 5, "a, b, c") + if got == msg.SummaryScopes { + t.Errorf("%s SummaryScopes has no format verb", lang) + } + } +} diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go new file mode 100644 index 00000000..06f9717e --- /dev/null +++ b/cmd/auth/login_test.go @@ -0,0 +1,292 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "sort" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestSuggestDomain_PrefixMatch(t *testing.T) { + known := map[string]bool{ + "calendar": true, + "task": true, + "drive": true, + "im": true, + } + + // Input is prefix of known domain + if s := suggestDomain("cal", known); s != "calendar" { + t.Errorf("expected 'calendar', got %q", s) + } + + // Known domain is prefix of input + if s := suggestDomain("calendar_extra", known); s != "calendar" { + t.Errorf("expected 'calendar', got %q", s) + } +} + +func TestSuggestDomain_NoMatch(t *testing.T) { + known := map[string]bool{ + "calendar": true, + "task": true, + } + + if s := suggestDomain("zzz", known); s != "" { + t.Errorf("expected empty suggestion, got %q", s) + } +} + +func TestSuggestDomain_ExactMatch(t *testing.T) { + known := map[string]bool{ + "calendar": true, + } + + // Exact match: input is prefix of known AND known is prefix of input + if s := suggestDomain("calendar", known); s != "calendar" { + t.Errorf("expected 'calendar', got %q", s) + } +} + +func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) { + // Empty AuthTypes defaults to ["user"] + sc := common.Shortcut{AuthTypes: nil} + if !shortcutSupportsIdentity(sc, "user") { + t.Error("expected default to support 'user'") + } + if shortcutSupportsIdentity(sc, "bot") { + t.Error("expected default to NOT support 'bot'") + } +} + +func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) { + sc := common.Shortcut{AuthTypes: []string{"user", "bot"}} + if !shortcutSupportsIdentity(sc, "user") { + t.Error("expected to support 'user'") + } + if !shortcutSupportsIdentity(sc, "bot") { + t.Error("expected to support 'bot'") + } + if shortcutSupportsIdentity(sc, "tenant") { + t.Error("expected to NOT support 'tenant'") + } +} + +func TestShortcutSupportsIdentity_BotOnly(t *testing.T) { + sc := common.Shortcut{AuthTypes: []string{"bot"}} + if shortcutSupportsIdentity(sc, "user") { + t.Error("expected bot-only to NOT support 'user'") + } + if !shortcutSupportsIdentity(sc, "bot") { + t.Error("expected bot-only to support 'bot'") + } +} + +func TestCompleteDomain(t *testing.T) { + projects := registry.ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + // Complete from empty prefix + completions := completeDomain("") + if len(completions) == 0 { + t.Fatal("expected completions for empty prefix") + } + // All completions should match from_meta projects + if len(completions) != len(projects) { + t.Errorf("expected %d completions, got %d", len(projects), len(completions)) + } + + // Complete with partial prefix + completions = completeDomain("cal") + for _, c := range completions { + if c != "calendar" && c[:3] != "cal" { + t.Errorf("unexpected completion %q for prefix 'cal'", c) + } + } +} + +func TestCompleteDomain_CommaSeparated(t *testing.T) { + projects := registry.ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + // After a comma, should complete the next segment + completions := completeDomain("calendar,") + for _, c := range completions { + if c[:9] != "calendar," { + t.Errorf("expected 'calendar,' prefix, got %q", c) + } + } +} + +func TestAllKnownDomains(t *testing.T) { + domains := allKnownDomains() + if len(domains) == 0 { + t.Fatal("expected non-empty known domains") + } + + // Should include from_meta projects + for _, p := range registry.ListFromMetaProjects() { + if !domains[p] { + t.Errorf("expected from_meta project %q in known domains", p) + } + } +} + +func TestSortedKnownDomains(t *testing.T) { + sorted := sortedKnownDomains() + if len(sorted) == 0 { + t.Fatal("expected non-empty sorted domains") + } + + if !sort.StringsAreSorted(sorted) { + t.Error("expected sorted result") + } + + // Should match allKnownDomains + known := allKnownDomains() + if len(sorted) != len(known) { + t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known)) + } +} + +func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) { + for _, name := range getShortcutOnlyDomainNames() { + zhDesc := registry.GetServiceDescription(name, "zh") + enDesc := registry.GetServiceDescription(name, "en") + if zhDesc == "" { + t.Errorf("missing zh description for shortcut-only domain %q", name) + } + if enDesc == "" { + t.Errorf("missing en description for shortcut-only domain %q", name) + } + } +} + +func TestCollectScopesForDomains(t *testing.T) { + projects := registry.ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + scopes := collectScopesForDomains([]string{"calendar"}, "user") + if len(scopes) == 0 { + t.Fatal("expected non-empty scopes for calendar domain") + } + + // Should be sorted + if !sort.StringsAreSorted(scopes) { + t.Error("expected sorted result") + } + + // Should include at least the API scopes + apiScopes := registry.CollectScopesForProjects([]string{"calendar"}, "user") + for _, s := range apiScopes { + found := false + for _, cs := range scopes { + if cs == s { + found = true + break + } + } + if !found { + t.Errorf("API scope %q missing from collectScopesForDomains result", s) + } + } +} + +func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) { + scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user") + if len(scopes) != 0 { + t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes)) + } +} + +func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) { + domains := getDomainMetadata("zh") + nameSet := make(map[string]bool) + for _, dm := range domains { + nameSet[dm.Name] = true + } + + // from_meta projects must be present + for _, p := range registry.ListFromMetaProjects() { + if !nameSet[p] { + t.Errorf("from_meta project %q missing from getDomainMetadata", p) + } + } +} + +func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) { + domains := getDomainMetadata("zh") + nameSet := make(map[string]bool) + for _, dm := range domains { + nameSet[dm.Name] = true + } + + for _, name := range getShortcutOnlyDomainNames() { + if !nameSet[name] { + t.Errorf("shortcut-only domain %q missing from getDomainMetadata", name) + } + } +} + +func TestGetDomainMetadata_Sorted(t *testing.T) { + domains := getDomainMetadata("zh") + for i := 1; i < len(domains); i++ { + if domains[i].Name < domains[i-1].Name { + t.Errorf("not sorted: %q before %q", domains[i-1].Name, domains[i].Name) + } + } +} + +func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) { + domains := getDomainMetadata("zh") + for _, dm := range domains { + if dm.Title == "" { + t.Errorf("domain %q has empty Title", dm.Name) + } + } +} + +func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + // TestFactory has IsTerminal=false by default + opts := &LoginOptions{Factory: f, Ctx: context.Background()} + err := authLoginRun(opts) + if err == nil { + t.Fatal("expected error for non-terminal without flags") + } + // Should mention specifying scopes + msg := err.Error() + if !strings.Contains(msg, "scopes") { + t.Errorf("expected error to mention scopes, got: %s", msg) + } + // Stderr should contain background hint + stderrStr := stderr.String() + if !strings.Contains(stderrStr, "background") { + t.Errorf("expected stderr to mention background, got: %s", stderrStr) + } +} + +func TestGetDomainMetadata_ExcludesEvent(t *testing.T) { + domains := getDomainMetadata("zh") + for _, dm := range domains { + if dm.Name == "event" { + t.Error("event should not appear in interactive domain list") + } + } +} diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go new file mode 100644 index 00000000..4914120c --- /dev/null +++ b/cmd/auth/logout.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// LogoutOptions holds all inputs for auth logout. +type LogoutOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdAuthLogout creates the auth logout subcommand. +func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Command { + opts := &LogoutOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "logout", + Short: "Log out (clear token)", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return authLogoutRun(opts) + }, + } + + return cmd +} + +func authLogoutRun(opts *LogoutOptions) error { + f := opts.Factory + + multi, _ := core.LoadMultiAppConfig() + if multi == nil || len(multi.Apps) == 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.") + return nil + } + + app := &multi.Apps[0] + if len(app.Users) == 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") + return nil + } + + for _, user := range app.Users { + if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err) + } + } + app.Users = []core.AppUser{} + if err := core.SaveMultiAppConfig(multi); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + output.PrintSuccess(f.IOStreams.ErrOut, "Logged out") + return nil +} diff --git a/cmd/auth/scopes.go b/cmd/auth/scopes.go new file mode 100644 index 00000000..23f8ef81 --- /dev/null +++ b/cmd/auth/scopes.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +// ScopesOptions holds all inputs for auth scopes. +type ScopesOptions struct { + Factory *cmdutil.Factory + Ctx context.Context + Format string +} + +// NewCmdAuthScopes creates the auth scopes subcommand. +func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobra.Command { + opts := &ScopesOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "scopes", + Short: "Query scopes enabled for the app", + RunE: func(cmd *cobra.Command, args []string) error { + opts.Ctx = cmd.Context() + if runF != nil { + return runF(opts) + } + return authScopesRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") + + return cmd +} + +func authScopesRun(opts *ScopesOptions) error { + f := opts.Factory + + config, err := f.Config() + if err != nil { + return err + } + fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n") + appInfo, err := getAppInfo(opts.Ctx, f, config.AppID) + if err != nil { + return output.ErrWithHint(output.ExitAPI, "permission", + fmt.Sprintf("failed to get app scope info: %v", err), + "ensure the app has enabled the application:application:self_manage scope.") + } + if opts.Format == "pretty" { + fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID) + fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes)) + for _, s := range appInfo.UserScopes { + fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s) + } + } else { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "appId": config.AppID, + "brand": config.Brand, + "tokenType": "user", + "userScopes": appInfo.UserScopes, + "count": len(appInfo.UserScopes), + }) + } + return nil +} diff --git a/cmd/auth/status.go b/cmd/auth/status.go new file mode 100644 index 00000000..55abfe58 --- /dev/null +++ b/cmd/auth/status.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "time" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// StatusOptions holds all inputs for auth status. +type StatusOptions struct { + Factory *cmdutil.Factory + Verify bool +} + +// NewCmdAuthStatus creates the auth status subcommand. +func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { + opts := &StatusOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "status", + Short: "View current auth status", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return authStatusRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)") + + return cmd +} + +func authStatusRun(opts *StatusOptions) error { + f := opts.Factory + + config, err := f.Config() + if err != nil { + return err + } + + defaultAs := config.DefaultAs + if defaultAs == "" { + defaultAs = "auto" + } + result := map[string]interface{}{ + "appId": config.AppID, + "brand": config.Brand, + "defaultAs": defaultAs, + } + + if config.UserOpenId == "" { + result["identity"] = "bot" + result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in." + output.PrintJson(f.IOStreams.Out, result) + return nil + } + + stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId) + if stored == nil { + result["identity"] = "bot" + result["userName"] = config.UserName + result["userOpenId"] = config.UserOpenId + result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login" + output.PrintJson(f.IOStreams.Out, result) + return nil + } + + status := larkauth.TokenStatus(stored) + if status == "expired" { + result["identity"] = "bot" + result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login" + } else { + result["identity"] = "user" + } + result["userName"] = config.UserName + result["userOpenId"] = config.UserOpenId + result["tokenStatus"] = status + result["scope"] = stored.Scope + result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339) + result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339) + result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339) + + // --verify: call the server to confirm token is actually usable. + if opts.Verify && status != "expired" { + verified, verifyErr := verifyTokenOnServer(f, config) + result["verified"] = verified + if verifyErr != "" { + result["verifyError"] = verifyErr + } + } + + output.PrintJson(f.IOStreams.Out, result) + return nil +} + +// verifyTokenOnServer obtains a valid access token (refreshing if needed) +// and calls /authen/v1/user_info to confirm the server accepts it. +// Returns (true, "") on success or (false, reason) on failure. +func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) { + httpClient, err := f.HttpClient() + if err != nil { + return false, "failed to create HTTP client: " + err.Error() + } + + token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut)) + if err != nil { + return false, "token unusable: " + err.Error() + } + + sdk, err := f.LarkClient() + if err != nil { + return false, "failed to create SDK client: " + err.Error() + } + + if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil { + return false, "server rejected token: " + err.Error() + } + + return true, "" +} diff --git a/cmd/completion/completion.go b/cmd/completion/completion.go new file mode 100644 index 00000000..574365b7 --- /dev/null +++ b/cmd/completion/completion.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package completion + +import ( + "fmt" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdCompletion creates the completion command that generates shell completion scripts. +func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "completion ", + Short: "Generate shell completion scripts", + Long: "Generate shell completion scripts for bash, zsh, fish, or powershell.", + Hidden: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + root := cmd.Root() + out := f.IOStreams.Out + switch args[0] { + case "bash": + return root.GenBashCompletionV2(out, true) + case "zsh": + return root.GenZshCompletion(out) + case "fish": + return root.GenFishCompletion(out, true) + case "powershell": + return root.GenPowerShellCompletionWithDesc(out) + default: + return fmt.Errorf("unsupported shell: %s", args[0]) + } + }, + } + cmdutil.DisableAuthCheck(cmd) + return cmd +} diff --git a/cmd/config/config.go b/cmd/config/config.go new file mode 100644 index 00000000..055ecfda --- /dev/null +++ b/cmd/config/config.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/spf13/cobra" +) + +// NewCmdConfig creates the config command with subcommands. +func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Global CLI configuration management", + } + cmdutil.DisableAuthCheck(cmd) + + cmd.AddCommand(NewCmdConfigInit(f, nil)) + cmd.AddCommand(NewCmdConfigRemove(f, nil)) + cmd.AddCommand(NewCmdConfigShow(f, nil)) + cmd.AddCommand(NewCmdConfigDefaultAs(f)) + return cmd +} + +func parseBrand(value string) core.LarkBrand { + if value == "lark" { + return core.BrandLark + } + return core.BrandFeishu +} diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go new file mode 100644 index 00000000..65642781 --- /dev/null +++ b/cmd/config/config_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func TestConfigInitCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret123\n") + + var gotOpts *ConfigInitOptions + cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--app-id", "cli_test", "--app-secret-stdin", "--brand", "lark"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.AppID != "cli_test" { + t.Errorf("expected AppID cli_test, got %s", gotOpts.AppID) + } + if !gotOpts.AppSecretStdin { + t.Error("expected AppSecretStdin=true") + } + if gotOpts.Brand != "lark" { + t.Errorf("expected Brand lark, got %s", gotOpts.Brand) + } +} + +func TestConfigShowCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *ConfigShowOptions + cmd := NewCmdConfigShow(f, func(opts *ConfigShowOptions) error { + gotOpts = opts + return nil + }) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } +} + +func TestConfigInitCmd_LangFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ConfigInitOptions + cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { + gotOpts = opts + return nil + }) + f.IOStreams.In = strings.NewReader("y\n") + cmd.SetArgs([]string{"--app-id", "x", "--app-secret-stdin", "--lang", "en"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Lang != "en" { + t.Errorf("expected Lang en, got %s", gotOpts.Lang) + } + if !gotOpts.langExplicit { + t.Error("expected langExplicit=true when --lang is passed") + } +} + +func TestConfigInitCmd_LangDefault(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ConfigInitOptions + cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { + gotOpts = opts + return nil + }) + f.IOStreams.In = strings.NewReader("y\n") + cmd.SetArgs([]string{"--app-id", "x", "--app-secret-stdin"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Lang != "zh" { + t.Errorf("expected default Lang zh, got %s", gotOpts.Lang) + } + if gotOpts.langExplicit { + t.Error("expected langExplicit=false when --lang is not passed") + } +} + +func TestHasAnyNonInteractiveFlag(t *testing.T) { + tests := []struct { + name string + opts ConfigInitOptions + want bool + }{ + {"empty", ConfigInitOptions{}, false}, + {"new", ConfigInitOptions{New: true}, true}, + {"app-id", ConfigInitOptions{AppID: "x"}, true}, + {"app-secret-stdin", ConfigInitOptions{AppSecretStdin: true}, true}, + {"app-id+secret-stdin", ConfigInitOptions{AppID: "x", AppSecretStdin: true}, true}, + {"lang-only", ConfigInitOptions{Lang: "en"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.opts.hasAnyNonInteractiveFlag() + if got != tt.want { + t.Errorf("hasAnyNonInteractiveFlag() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + // TestFactory has IsTerminal=false by default + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Lang: "zh"} + err := configInitRun(opts) + if err == nil { + t.Fatal("expected error for non-terminal without flags") + } + msg := err.Error() + if !strings.Contains(msg, "--new") { + t.Errorf("expected error to mention --new, got: %s", msg) + } + if !strings.Contains(msg, "terminal") { + t.Errorf("expected error to mention terminal, got: %s", msg) + } +} + +func TestConfigRemoveCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ConfigRemoveOptions + cmd := NewCmdConfigRemove(f, func(opts *ConfigRemoveOptions) error { + gotOpts = opts + return nil + }) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected opts to be set") + } + if gotOpts.Factory != f { + t.Fatal("expected factory to be preserved in options") + } +} diff --git a/cmd/config/default_as.go b/cmd/config/default_as.go new file mode 100644 index 00000000..0600de5d --- /dev/null +++ b/cmd/config/default_as.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// NewCmdConfigDefaultAs creates the "config default-as" subcommand. +func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "default-as [user|bot|auto]", + Short: "View or set default identity type", + Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + multi, err := core.LoadMultiAppConfig() + if err != nil { + return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init") + } + + if len(args) == 0 { + current := multi.Apps[0].DefaultAs + if current == "" { + current = "auto" + } + fmt.Fprintf(f.IOStreams.Out, "default-as: %s\n", current) + return nil + } + + value := args[0] + if value != "user" && value != "bot" && value != "auto" { + return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value) + } + + multi.Apps[0].DefaultAs = value + if err := core.SaveMultiAppConfig(multi); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value) + return nil + }, + } + return cmd +} diff --git a/cmd/config/init.go b/cmd/config/init.go new file mode 100644 index 00000000..8ddff761 --- /dev/null +++ b/cmd/config/init.go @@ -0,0 +1,305 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// ConfigInitOptions holds all inputs for config init. +type ConfigInitOptions struct { + Factory *cmdutil.Factory + Ctx context.Context + AppID string + appSecret string // internal only; populated from stdin, never from a CLI flag + AppSecretStdin bool // read app-secret from stdin (avoids process list exposure) + Brand string + New bool + Lang string + langExplicit bool // true when --lang was explicitly passed +} + +// NewCmdConfigInit creates the config init subcommand. +func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command { + opts := &ConfigInitOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize configuration (app-id / app-secret-stdin / brand)", + Long: `Initialize configuration (app-id / app-secret-stdin / brand). + +For AI agents: use --new to create a new app. The command blocks until the user +completes setup in the browser. Run it in the background and retrieve the +verification URL from its output.`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Ctx = cmd.Context() + opts.langExplicit = cmd.Flags().Changed("lang") + if runF != nil { + return runF(opts) + } + return configInitRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)") + cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)") + cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure") + cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)") + cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)") + + return cmd +} + +// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set. +func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool { + return o.New || o.AppID != "" || o.AppSecretStdin +} + +// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID. +func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipAppID string) { + if existing == nil { + return + } + for _, app := range existing.Apps { + if app.AppId == skipAppID { + continue + } + core.RemoveSecretStore(app.AppSecret, f.Keychain) + for _, user := range app.Users { + auth.RemoveStoredToken(app.AppId, user.UserOpenId) + } + } +} + +// saveAsOnlyApp overwrites config.json with a single-app config. +func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { + config := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{}, + }}, + } + return core.SaveMultiAppConfig(config) +} + +func configInitRun(opts *ConfigInitOptions) error { + f := opts.Factory + + // Read secret from stdin if --app-secret-stdin is set + if opts.AppSecretStdin { + scanner := bufio.NewScanner(f.IOStreams.In) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return output.ErrValidation("failed to read secret from stdin: %v", err) + } + return output.ErrValidation("stdin is empty, expected app secret") + } + opts.appSecret = strings.TrimSpace(scanner.Text()) + if opts.appSecret == "" { + return output.ErrValidation("app secret read from stdin is empty") + } + } + + existing, err := core.LoadMultiAppConfig() + if err != nil { + existing = nil // treat as empty + } + + // Mode 1: Non-interactive + if opts.AppID != "" && opts.appSecret != "" { + brand := parseBrand(opts.Brand) + secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + cleanupOldConfig(existing, f, opts.AppID) + if err := saveAsOnlyApp(opts.AppID, secret, brand, opts.Lang); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand}) + return nil + } + + // For interactive modes, prompt language selection if --lang was not explicitly set + if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() { + savedLang := "" + if existing != nil && len(existing.Apps) > 0 { + savedLang = existing.Apps[0].Lang + } + lang, err := promptLangSelection(savedLang) + if err != nil { + if err == huh.ErrUserAborted { + return output.ErrBare(1) + } + return err + } + opts.Lang = lang + } + + msg := getInitMsg(opts.Lang) + + // Mode 3: Create new app directly (--new) + if opts.New { + result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg) + if err != nil { + return err + } + if result == nil { + return output.ErrValidation("app creation returned no result") + } + existing, _ := core.LoadMultiAppConfig() + secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + cleanupOldConfig(existing, f, result.AppID) + if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) + return nil + } + + // Mode 4: Interactive TUI (terminal) + if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal { + result, err := runInteractiveConfigInit(opts.Ctx, f, msg) + if err != nil { + return err + } + if result == nil { + return output.ErrValidation("App ID and App Secret cannot be empty") + } + + existing, _ := core.LoadMultiAppConfig() + + if result.AppSecret != "" { + // New secret provided (either from "create" or "existing" with input) + secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + cleanupOldConfig(existing, f, result.AppID) + if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + } else if result.Mode == "existing" && result.AppID != "" { + // Existing app with unchanged secret — update app ID and brand only + if existing != nil && len(existing.Apps) > 0 { + existing.Apps[0].AppId = result.AppID + existing.Apps[0].Brand = result.Brand + existing.Apps[0].Lang = opts.Lang + if err := core.SaveMultiAppConfig(existing); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + } else { + return output.ErrValidation("App Secret cannot be empty for new configuration") + } + } else { + return output.ErrValidation("App ID and App Secret cannot be empty") + } + + if result.Mode == "existing" { + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID)) + } + return nil + } + + // Non-terminal: cannot run interactive mode, guide user to --new + if !f.IOStreams.IsTerminal { + return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.") + } + + // Mode 5: Legacy interactive (readline fallback) + firstApp := (*core.AppConfig)(nil) + if existing != nil && len(existing.Apps) > 0 { + firstApp = &existing.Apps[0] + } + + reader := bufio.NewReader(f.IOStreams.In) + readLine := func(prompt string) (string, error) { + fmt.Fprintf(f.IOStreams.ErrOut, "%s: ", prompt) + line, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return "", fmt.Errorf("failed to read input: %w", err) + } + if err == io.EOF && strings.TrimSpace(line) == "" { + return "", fmt.Errorf("input terminated unexpectedly (EOF)") + } + return strings.TrimSpace(line), nil + } + + prompt := "App ID" + if firstApp != nil && firstApp.AppId != "" { + prompt += fmt.Sprintf(" [%s]", firstApp.AppId) + } + appIdInput, err := readLine(prompt) + if err != nil { + return output.ErrValidation("%s", err) + } + + prompt = "App Secret" + if firstApp != nil && !firstApp.AppSecret.IsZero() { + prompt += " [****]" + } + appSecretInput, err := readLine(prompt) + if err != nil { + return output.ErrValidation("%s", err) + } + + prompt = "Brand (lark/feishu)" + if firstApp != nil && firstApp.Brand != "" { + prompt += fmt.Sprintf(" [%s]", firstApp.Brand) + } else { + prompt += " [feishu]" + } + brandInput, err := readLine(prompt) + if err != nil { + return output.ErrValidation("%s", err) + } + + resolvedAppId := appIdInput + if resolvedAppId == "" && firstApp != nil { + resolvedAppId = firstApp.AppId + } + var resolvedSecret core.SecretInput + if appSecretInput != "" { + resolvedSecret = core.PlainSecret(appSecretInput) + } else if firstApp != nil { + resolvedSecret = firstApp.AppSecret + } + resolvedBrand := brandInput + if resolvedBrand == "" && firstApp != nil { + resolvedBrand = string(firstApp.Brand) + } + if resolvedBrand == "" { + resolvedBrand = "feishu" + } + + if resolvedAppId == "" || resolvedSecret.IsZero() { + return output.ErrValidation("App ID and App Secret cannot be empty") + } + + storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + cleanupOldConfig(existing, f, resolvedAppId) + if err := saveAsOnlyApp(resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) + return nil +} diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go new file mode 100644 index 00000000..0172079d --- /dev/null +++ b/cmd/config/init_interactive.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "fmt" + "net/http" + + "github.com/charmbracelet/huh" + "github.com/larksuite/cli/internal/build" + qrcode "github.com/skip2/go-qrcode" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// configInitResult holds the result of the interactive config init flow. +type configInitResult struct { + Mode string // "create" or "existing" + Brand core.LarkBrand + AppID string + AppSecret string +} + +// runInteractiveConfigInit shows an interactive TUI for config init. +func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) { + // Phase 1: Choose mode + var mode string + form1 := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(msg.SelectAction). + Options( + huh.NewOption(msg.CreateNewApp, "create"), + huh.NewOption(msg.ConfigExistingApp, "existing"), + ). + Value(&mode), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form1.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + + if mode == "existing" { + return runExistingAppForm(f, msg) + } + + return runCreateAppFlow(ctx, f, "", msg) +} + +// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand. +func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) { + // Load existing config for defaults + existing, _ := core.LoadMultiAppConfig() + var firstApp *core.AppConfig + if existing != nil && len(existing.Apps) > 0 { + firstApp = &existing.Apps[0] + } + + var appID, appSecret, brand string + + appIDInput := huh.NewInput(). + Title("App ID"). + Value(&appID) + if firstApp != nil && firstApp.AppId != "" { + appIDInput = appIDInput.Placeholder(firstApp.AppId) + } else { + appIDInput = appIDInput.Placeholder("cli_xxxx") + } + + appSecretInput := huh.NewInput(). + Title("App Secret"). + EchoMode(huh.EchoModePassword). + Value(&appSecret) + if firstApp != nil && !firstApp.AppSecret.IsZero() { + appSecretInput = appSecretInput.Placeholder("****") + } else { + appSecretInput = appSecretInput.Placeholder("xxxx") + } + + brand = "feishu" + if firstApp != nil && firstApp.Brand != "" { + brand = string(firstApp.Brand) + } + + form := huh.NewForm( + huh.NewGroup( + appIDInput, + appSecretInput, + huh.NewSelect[string](). + Title(msg.Platform). + Options( + huh.NewOption(msg.Feishu, "feishu"), + huh.NewOption("Lark", "lark"), + ). + Value(&brand), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + + // Resolve defaults + if appID == "" && firstApp != nil { + appID = firstApp.AppId + } + if appSecret == "" && firstApp != nil && !firstApp.AppSecret.IsZero() { + // Keep existing secret - caller will handle + return &configInitResult{ + Mode: "existing", + Brand: parseBrand(brand), + AppID: appID, + }, nil + } + + if appID == "" || appSecret == "" { + return nil, output.ErrValidation("App ID and App Secret cannot be empty") + } + + return &configInitResult{ + Mode: "existing", + Brand: parseBrand(brand), + AppID: appID, + AppSecret: appSecret, + }, nil +} + +// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow. +// If brandOverride is non-empty, skip the interactive brand selection. +func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) { + var larkBrand core.LarkBrand + if brandOverride != "" { + larkBrand = brandOverride + } else { + // Phase 2: Brand selection + var brand string + form2 := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(msg.SelectPlatform). + Options( + huh.NewOption(msg.Feishu, "feishu"), + huh.NewOption("Lark", "lark"), + ). + Value(&brand), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form2.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + larkBrand = parseBrand(brand) + } + + // Step 1: Request app registration (begin) + httpClient := &http.Client{} + authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut) + if err != nil { + return nil, output.ErrAuth("app registration failed: %v", err) + } + + // Step 2: Build and display verification URL + QR code + verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version) + + // Show QR code in terminal + qr, qrErr := qrcode.New(verificationURL, qrcode.Medium) + if qrErr == nil { + fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false)) + } + + fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink) + fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL) + + // Step 3: Poll for result + fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan) + result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) + if err != nil { + return nil, output.ErrAuth("%v", err) + } + + // Step 4: Handle Lark brand special case + // If tenant_brand=lark and no client_secret, retry with lark brand endpoint + if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" { + // fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant) + result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) + if err != nil { + return nil, output.ErrAuth("lark endpoint retry failed: %v", err) + } + } + + if result.ClientID == "" || result.ClientSecret == "" { + return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret") + } + + // Determine final brand from response + finalBrand := larkBrand + if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" { + finalBrand = core.BrandLark + } else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" { + finalBrand = core.BrandFeishu + } + + fmt.Fprintln(f.IOStreams.ErrOut) + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID)) + + return &configInitResult{ + Mode: "create", + Brand: finalBrand, + AppID: result.ClientID, + AppSecret: result.ClientSecret, + }, nil +} diff --git a/cmd/config/init_messages.go b/cmd/config/init_messages.go new file mode 100644 index 00000000..54e2dbbe --- /dev/null +++ b/cmd/config/init_messages.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "github.com/charmbracelet/huh" + + "github.com/larksuite/cli/internal/cmdutil" +) + +type initMsg struct { + SelectAction string + CreateNewApp string + ConfigExistingApp string + Platform string + SelectPlatform string + Feishu string + ScanOrOpenLink string + WaitingForScan string + DetectedLarkTenant string + AppCreated string + ConfigSaved string +} + +var initMsgZh = &initMsg{ + SelectAction: "选择操作", + CreateNewApp: "一键配置应用 (推荐) ", + ConfigExistingApp: "手动输入应用凭证", + Platform: "平台", + SelectPlatform: "选择平台", + Feishu: "飞书", + ScanOrOpenLink: "\n打开以下链接配置应用:\n\n", + WaitingForScan: "等待配置应用...", + DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...", + AppCreated: "应用配置成功! App ID: %s", + ConfigSaved: "应用配置成功! App ID: %s", +} + +var initMsgEn = &initMsg{ + SelectAction: "Select action", + CreateNewApp: "Set up your app with one click (Recommended)", + ConfigExistingApp: "Enter app credentials yourself", + Platform: "Platform", + SelectPlatform: "Select platform", + Feishu: "Feishu", + ScanOrOpenLink: "\nOpen the link below to configure app:\n\n", + WaitingForScan: "Waiting for app configuration...", + DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...", + AppCreated: "App configured! App ID: %s", + ConfigSaved: "App configured! App ID: %s", +} + +func getInitMsg(lang string) *initMsg { + if lang == "en" { + return initMsgEn + } + return initMsgZh +} + +// promptLangSelection shows an interactive language picker and returns the chosen lang code. +// savedLang is used as the pre-selected default (from existing config). +func promptLangSelection(savedLang string) (string, error) { + lang := savedLang + if lang != "en" { + lang = "zh" + } + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Language / 语言"). + Options( + huh.NewOption("中文", "zh"), + huh.NewOption("English", "en"), + ). + Value(&lang), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form.Run(); err != nil { + return "", err + } + return lang, nil +} diff --git a/cmd/config/init_messages_test.go b/cmd/config/init_messages_test.go new file mode 100644 index 00000000..0e2ebe56 --- /dev/null +++ b/cmd/config/init_messages_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + "testing" +) + +func TestGetInitMsg_Zh(t *testing.T) { + msg := getInitMsg("zh") + if msg != initMsgZh { + t.Error("expected zh message set") + } + if msg.SelectAction != "选择操作" { + t.Errorf("unexpected SelectAction: %s", msg.SelectAction) + } +} + +func TestGetInitMsg_En(t *testing.T) { + msg := getInitMsg("en") + if msg != initMsgEn { + t.Error("expected en message set") + } + if msg.SelectAction != "Select action" { + t.Errorf("unexpected SelectAction: %s", msg.SelectAction) + } +} + +func TestGetInitMsg_DefaultsToZh(t *testing.T) { + for _, lang := range []string{"", "fr", "ja", "unknown"} { + msg := getInitMsg(lang) + if msg != initMsgZh { + t.Errorf("getInitMsg(%q) should default to zh", lang) + } + } +} + +func TestInitMsgZh_AllFieldsNonEmpty(t *testing.T) { + assertAllFieldsNonEmpty(t, initMsgZh, "zh") +} + +func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) { + assertAllFieldsNonEmpty(t, initMsgEn, "en") +} + +func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) { + t.Helper() + fields := map[string]string{ + "SelectAction": msg.SelectAction, + "CreateNewApp": msg.CreateNewApp, + "ConfigExistingApp": msg.ConfigExistingApp, + "Platform": msg.Platform, + "SelectPlatform": msg.SelectPlatform, + "Feishu": msg.Feishu, + "ScanOrOpenLink": msg.ScanOrOpenLink, + "WaitingForScan": msg.WaitingForScan, + "DetectedLarkTenant": msg.DetectedLarkTenant, + "AppCreated": msg.AppCreated, + "ConfigSaved": msg.ConfigSaved, + } + for name, val := range fields { + if val == "" { + t.Errorf("%s.%s is empty", label, name) + } + } +} + +func TestInitMsg_FormatStrings(t *testing.T) { + for _, lang := range []string{"zh", "en"} { + msg := getInitMsg(lang) + // AppCreated and ConfigSaved should contain %s for App ID + got := fmt.Sprintf(msg.AppCreated, "cli_test123") + if got == msg.AppCreated { + t.Errorf("%s AppCreated has no format verb", lang) + } + got = fmt.Sprintf(msg.ConfigSaved, "cli_test123") + if got == msg.ConfigSaved { + t.Errorf("%s ConfigSaved has no format verb", lang) + } + } +} diff --git a/cmd/config/remove.go b/cmd/config/remove.go new file mode 100644 index 00000000..0d7835e8 --- /dev/null +++ b/cmd/config/remove.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// ConfigRemoveOptions holds all inputs for config remove. +type ConfigRemoveOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdConfigRemove creates the config remove subcommand. +func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) error) *cobra.Command { + opts := &ConfigRemoveOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "remove", + Short: "Remove app configuration (clears all tokens and config)", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return configRemoveRun(opts) + }, + } + + return cmd +} + +func configRemoveRun(opts *ConfigRemoveOptions) error { + f := opts.Factory + + config, err := core.LoadMultiAppConfig() + if err != nil || config == nil || len(config.Apps) == 0 { + return output.ErrValidation("not configured yet") + } + + // Clean up keychain entries for all apps + for _, app := range config.Apps { + core.RemoveSecretStore(app.AppSecret, f.Keychain) + for _, user := range app.Users { + auth.RemoveStoredToken(app.AppId, user.UserOpenId) + } + } + + // Save empty config + empty := &core.MultiAppConfig{Apps: []core.AppConfig{}} + if err := core.SaveMultiAppConfig(empty); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + output.PrintSuccess(f.IOStreams.ErrOut, "Configuration removed") + userCount := 0 + for _, app := range config.Apps { + userCount += len(app.Users) + } + if userCount > 0 { + fmt.Fprintf(f.IOStreams.ErrOut, "Cleared tokens for %d users\n", userCount) + } + return nil +} diff --git a/cmd/config/show.go b/cmd/config/show.go new file mode 100644 index 00000000..cdee9e34 --- /dev/null +++ b/cmd/config/show.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + "strings" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// ConfigShowOptions holds all inputs for config show. +type ConfigShowOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdConfigShow creates the config show subcommand. +func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *cobra.Command { + opts := &ConfigShowOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "show", + Short: "Show current configuration", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return configShowRun(opts) + }, + } + + return cmd +} + +func configShowRun(opts *ConfigShowOptions) error { + f := opts.Factory + + config, err := core.LoadMultiAppConfig() + if err != nil || config == nil || len(config.Apps) == 0 { + fmt.Fprintf(f.IOStreams.ErrOut, "Not configured yet. Config file path: %s\n", core.GetConfigPath()) + fmt.Fprintln(f.IOStreams.ErrOut, "Run `lark-cli config init` to initialize.") + return nil + } + app := config.Apps[0] + users := "(no logged-in users)" + if len(app.Users) > 0 { + var userStrs []string + for _, u := range app.Users { + userStrs = append(userStrs, fmt.Sprintf("%s (%s)", u.UserName, u.UserOpenId)) + } + users = strings.Join(userStrs, ", ") + } + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "appId": app.AppId, + "appSecret": "****", + "brand": app.Brand, + "lang": app.Lang, + "users": users, + }) + fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath()) + return nil +} diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go new file mode 100644 index 00000000..6edde0a5 --- /dev/null +++ b/cmd/doctor/doctor.go @@ -0,0 +1,235 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doctor + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// DoctorOptions holds inputs for the doctor command. +type DoctorOptions struct { + Factory *cmdutil.Factory + Ctx context.Context + Offline bool +} + +// NewCmdDoctor creates the doctor command. +func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command { + opts := &DoctorOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "doctor", + Short: "CLI health check: config, auth, and connectivity", + RunE: func(cmd *cobra.Command, args []string) error { + opts.Ctx = cmd.Context() + return doctorRun(opts) + }, + } + cmdutil.DisableAuthCheck(cmd) + cmd.Flags().BoolVar(&opts.Offline, "offline", false, "skip network checks (only verify local state)") + + return cmd +} + +// checkResult represents one diagnostic check. +type checkResult struct { + Name string `json:"name"` + Status string `json:"status"` // "pass", "fail", "skip" + Message string `json:"message"` + Hint string `json:"hint,omitempty"` +} + +func pass(name, msg string) checkResult { + return checkResult{Name: name, Status: "pass", Message: msg} +} + +func fail(name, msg, hint string) checkResult { + return checkResult{Name: name, Status: "fail", Message: msg, Hint: hint} +} + +func skip(name, msg string) checkResult { + return checkResult{Name: name, Status: "skip", Message: msg} +} + +func doctorRun(opts *DoctorOptions) error { + f := opts.Factory + var checks []checkResult + + // ── 1. Config file ── + _, err := core.LoadMultiAppConfig() + if err != nil { + checks = append(checks, fail("config_file", err.Error(), "run: lark-cli config init")) + return finishDoctor(f, checks) + } + checks = append(checks, pass("config_file", "config.json found")) + + // ── 2. App resolved ── + cfg, err := f.Config() + if err != nil { + hint := "" + var cfgErr *core.ConfigError + if errors.As(err, &cfgErr) { + hint = cfgErr.Hint + } + checks = append(checks, fail("app_resolved", err.Error(), hint)) + return finishDoctor(f, checks) + } + checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand))) + + ep := core.ResolveEndpoints(cfg.Brand) + + // ── 3. Token exists ── + if cfg.UserOpenId == "" { + checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help")) + checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) + return finishDoctor(f, checks) + } + stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId) + if stored == nil { + checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help")) + checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) + return finishDoctor(f, checks) + } + checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId))) + + // ── 4. Token local validity ── + status := larkauth.TokenStatus(stored) + switch status { + case "valid": + checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339))) + case "needs_refresh": + checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)")) + default: // expired + checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help")) + checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) + return finishDoctor(f, checks) + } + + // ── 5. Token server verification ── + if opts.Offline { + checks = append(checks, skip("token_verified", "skipped (--offline)")) + } else { + httpClient := mustHTTPClient(f) + token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut)) + if err != nil { + checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help")) + } else { + sdk, err := f.LarkClient() + if err != nil { + checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), "")) + } else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil { + checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help")) + } else { + checks = append(checks, pass("token_verified", "server confirmed token is valid")) + } + } + } + + // ── 6 & 7. Endpoint reachability ── + checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) + + return finishDoctor(f, checks) +} + +// networkChecks probes Open API and MCP endpoints concurrently. +func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult { + if opts.Offline { + return []checkResult{ + skip("endpoint_open", "skipped (--offline)"), + skip("endpoint_mcp", "skipped (--offline)"), + } + } + + httpClient := &http.Client{} + mcpURL := ep.MCP + "/mcp" + + type probeResult struct { + name string + url string + err error + } + + var wg sync.WaitGroup + results := make([]probeResult, 2) + + wg.Add(2) + go func() { + defer wg.Done() + defer func() { recover() }() + results[0] = probeResult{"endpoint_open", ep.Open, probeEndpoint(ctx, httpClient, ep.Open)} + }() + go func() { + defer wg.Done() + defer func() { recover() }() + results[1] = probeResult{"endpoint_mcp", mcpURL, probeEndpoint(ctx, httpClient, mcpURL)} + }() + wg.Wait() + + var checks []checkResult + for _, r := range results { + if r.err != nil { + checks = append(checks, fail(r.name, fmt.Sprintf("%s unreachable: %s", r.url, r.err), "check network or proxy settings")) + } else { + checks = append(checks, pass(r.name, r.url+" reachable")) + } + } + return checks +} + +// probeEndpoint sends a HEAD request to check reachability. +func probeEndpoint(ctx context.Context, client *http.Client, url string) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// mustHTTPClient returns f.HttpClient() or a default client. +func mustHTTPClient(f *cmdutil.Factory) *http.Client { + c, err := f.HttpClient() + if err != nil { + return &http.Client{Timeout: 30 * time.Second} + } + return c +} + +func finishDoctor(f *cmdutil.Factory, checks []checkResult) error { + allOK := true + for _, c := range checks { + if c.Status == "fail" { + allOK = false + break + } + } + + result := map[string]interface{}{ + "ok": allOK, + "checks": checks, + } + output.PrintJson(f.IOStreams.Out, result) + if !allOK { + return output.ErrBare(1) + } + return nil +} diff --git a/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go new file mode 100644 index 00000000..5ffd7709 --- /dev/null +++ b/cmd/doctor/doctor_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doctor + +import ( + "context" + "encoding/json" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func TestNewCmdDoctor_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdDoctor(f) + cmd.SetArgs([]string{"--offline"}) + + // We only test flag parsing; skip actual execution by intercepting RunE. + var gotOffline bool + origRunE := cmd.RunE + cmd.RunE = func(cmd2 *cobra.Command, args []string) error { + v, _ := cmd2.Flags().GetBool("offline") + gotOffline = v + return nil + } + _ = origRunE + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !gotOffline { + t.Error("expected --offline to be true") + } +} + +func TestFinishDoctor(t *testing.T) { + t.Run("all pass returns nil", func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + checks := []checkResult{ + pass("check1", "ok"), + skip("check2", "skipped"), + } + err := finishDoctor(f, checks) + if err != nil { + t.Fatalf("expected nil, got %v", err) + } + + var result struct { + OK bool `json:"ok"` + } + json.Unmarshal(stdout.Bytes(), &result) + if !result.OK { + t.Error("expected ok=true") + } + }) + + t.Run("any fail returns error", func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + checks := []checkResult{ + pass("check1", "ok"), + fail("check2", "bad", "fix it"), + } + err := finishDoctor(f, checks) + if err == nil { + t.Fatal("expected error, got nil") + } + + var result struct { + OK bool `json:"ok"` + } + json.Unmarshal(stdout.Bytes(), &result) + if result.OK { + t.Error("expected ok=false") + } + }) +} + +func TestNetworkChecks_Offline(t *testing.T) { + ep := core.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"} + opts := &DoctorOptions{Ctx: context.Background(), Offline: true} + checks := networkChecks(opts.Ctx, opts, ep) + if len(checks) != 2 { + t.Fatalf("expected 2 checks, got %d", len(checks)) + } + for _, c := range checks { + if c.Status != "skip" { + t.Errorf("expected skip, got %s for %s", c.Status, c.Name) + } + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 00000000..6cf9f624 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,307 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "strconv" + + "github.com/larksuite/cli/cmd/api" + "github.com/larksuite/cli/cmd/auth" + "github.com/larksuite/cli/cmd/completion" + cmdconfig "github.com/larksuite/cli/cmd/config" + "github.com/larksuite/cli/cmd/doctor" + "github.com/larksuite/cli/cmd/schema" + "github.com/larksuite/cli/cmd/service" + internalauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" + "github.com/spf13/cobra" +) + +const rootLong = `lark-cli — Lark/Feishu CLI tool. + +USAGE: + lark-cli [subcommand] [method] [options] + lark-cli api [--params ] [--data ] + lark-cli schema [--format pretty] + +EXAMPLES: + # View upcoming events + lark-cli calendar +agenda + + # List calendar events + lark-cli calendar events list --params '{"calendar_id":"primary"}' + + # Search users + lark-cli contact +search-user --query "John" + + # Generic API call + lark-cli api GET /open-apis/calendar/v4/calendars + +FLAGS: + --params URL/query parameters JSON + --data request body JSON (POST/PATCH/PUT/DELETE) + --as identity type: user | bot | auto (default: auto) + --format output format: json (default) | ndjson | table | csv | pretty + --page-all automatically paginate through all pages + --page-size page size (0 = use API default) + --page-limit max pages to fetch with --page-all (default: 10, 0 for unlimited) + --page-delay delay in ms between pages (default: 200, only with --page-all) + -o, --output output file path for binary responses + --dry-run print request without executing + +AI AGENT SKILLS: + lark-cli pairs with AI agent skills (Claude Code, etc.) that + teach the agent Lark API patterns, best practices, and workflows. + + Install all skills: + npx skills add larksuite/cli --all -y + + Or pick specific domains: + npx skills add larksuite/cli -s lark-calendar -y + npx skills add larksuite/cli -s lark-im -y + + Learn more: https://github.com/larksuite/cli#install-ai-agent-skills + +COMMUNITY: + GitHub: https://github.com/larksuite/cli + Issues: https://github.com/larksuite/cli/issues + Docs: https://open.feishu.cn/document/ + +More help: lark-cli --help` + +// Execute runs the root command and returns the process exit code. +func Execute() int { + f := cmdutil.NewDefault() + + rootCmd := &cobra.Command{ + Use: "lark-cli", + Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls", + Long: rootLong, + Version: build.Version, + } + installTipsHelpFunc(rootCmd) + rootCmd.SilenceErrors = true + rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + cmd.SilenceUsage = true + } + + rootCmd.AddCommand(cmdconfig.NewCmdConfig(f)) + rootCmd.AddCommand(auth.NewCmdAuth(f)) + rootCmd.AddCommand(doctor.NewCmdDoctor(f)) + rootCmd.AddCommand(api.NewCmdApi(f, nil)) + rootCmd.AddCommand(schema.NewCmdSchema(f, nil)) + rootCmd.AddCommand(completion.NewCmdCompletion(f)) + service.RegisterServiceCommands(rootCmd, f) + shortcuts.RegisterShortcuts(rootCmd, f) + + if err := rootCmd.Execute(); err != nil { + return handleRootError(f, err) + } + return 0 +} + +// handleRootError dispatches a command error to the appropriate handler +// and returns the process exit code. +func handleRootError(f *cmdutil.Factory, err error) int { + errOut := f.IOStreams.ErrOut + + // SecurityPolicyError uses a custom envelope format (string codes, challenge_url, retryable) + // that differs from the standard ErrDetail, so it's handled separately. + var spErr *internalauth.SecurityPolicyError + if errors.As(err, &spErr) { + writeSecurityPolicyError(errOut, spErr) + return 1 + } + + // All other structured errors normalize to ExitError. + if exitErr := asExitError(err); exitErr != nil { + if exitErr.Raw { + // Raw errors (e.g. from `api` command) already printed the full API + // response to stdout; skip enrichment and duplicate stderr envelope. + return exitErr.Code + } + enrichPermissionError(f, exitErr) + output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity)) + return exitErr.Code + } + + // Cobra errors (required flags, unknown commands, etc.) + fmt.Fprintln(errOut, "Error:", err) + return 1 +} + +// asExitError converts known structured error types to *output.ExitError. +// Returns nil for unrecognized errors (e.g. cobra flag errors). +func asExitError(err error) *output.ExitError { + var cfgErr *core.ConfigError + if errors.As(err, &cfgErr) { + return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint) + } + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return exitErr + } + return nil +} + +// writeSecurityPolicyError writes the security-policy-specific JSON envelope to w. +// This format intentionally differs from the standard ErrDetail envelope: +// it uses string codes ("challenge_required"/"access_denied") and extra fields +// (retryable, challenge_url) for machine-readable policy error handling. +func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyError) { + var codeStr string + switch spErr.Code { + case internalauth.LarkErrBlockByPolicyTryAuth: + codeStr = "challenge_required" + case internalauth.LarkErrBlockByPolicy: + codeStr = "access_denied" + default: + codeStr = strconv.Itoa(spErr.Code) + } + + errData := map[string]interface{}{ + "type": "auth_error", + "code": codeStr, + "message": spErr.Message, + "retryable": false, + } + if spErr.ChallengeURL != "" { + errData["challenge_url"] = spErr.ChallengeURL + } + if spErr.CLIHint != "" { + errData["hint"] = spErr.CLIHint + } + + env := map[string]interface{}{"ok": false, "error": errData} + b, err := json.MarshalIndent(env, "", " ") + if err != nil { + fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`) + return + } + fmt.Fprintln(w, string(b)) +} + +// installTipsHelpFunc wraps the default help function to append a TIPS section +// when a command has tips set via cmdutil.SetTips. +func installTipsHelpFunc(root *cobra.Command) { + defaultHelp := root.HelpFunc() + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + defaultHelp(cmd, args) + tips := cmdutil.GetTips(cmd) + if len(tips) == 0 { + return + } + out := cmd.OutOrStdout() + fmt.Fprintln(out) + fmt.Fprintln(out, "Tips:") + for _, tip := range tips { + fmt.Fprintf(out, " • %s\n", tip) + } + }) +} + +// enrichPermissionError adds console_url and improves the hint for permission errors. +// It differentiates between: +// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the API scope → hint to admin console +// - LarkErrUserScopeInsufficient (99991679): user has not authorized the scope → hint to auth login --scope +func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) { + if exitErr.Detail == nil || exitErr.Detail.Type != "permission" { + return + } + // Extract required scopes from API error detail + scopes := extractRequiredScopes(exitErr.Detail.Detail) + if len(scopes) == 0 { + return + } + + cfg, err := f.Config() + if err != nil { + return + } + + // Select the recommended (least-privilege) scope + scopeIfaces := make([]interface{}, len(scopes)) + for i, s := range scopes { + scopeIfaces[i] = s + } + recommended := registry.SelectRecommendedScope(scopeIfaces, "tenant") + if recommended == "" { + recommended = scopes[0] + } + + // Build admin console URL with the recommended scope + host := "open.feishu.cn" + if cfg.Brand == "lark" { + host = "open.larksuite.com" + } + consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended)) + + // Clear raw API detail — useful info is now in message/hint/console_url + exitErr.Detail.Detail = nil + + isBot := f.ResolvedIdentity.IsBot() + + larkCode := exitErr.Detail.Code + switch larkCode { + case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized: + // User has not authorized the scope → re-authorize + exitErr.Detail.Message = fmt.Sprintf("User not authorized: required scope %s [%d]", recommended, larkCode) + if isBot { + exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" + } else { + exitErr.Detail.Hint = fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended) + } + exitErr.Detail.ConsoleURL = consoleURL + + case output.LarkErrAppScopeNotEnabled: + // App has not enabled the API scope → admin console + exitErr.Detail.Message = fmt.Sprintf("App scope not enabled: required scope %s [%d]", recommended, larkCode) + exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" + exitErr.Detail.ConsoleURL = consoleURL + + default: + // Other permission errors (matched by keyword) + exitErr.Detail.Message = fmt.Sprintf("Permission denied: required scope %s [%d]", recommended, larkCode) + if isBot { + exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" + } else { + exitErr.Detail.Hint = fmt.Sprintf( + "enable scope in console (see console_url), or run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended) + } + exitErr.Detail.ConsoleURL = consoleURL + } +} + +// extractRequiredScopes extracts scope names from the API error's permission_violations field. +func extractRequiredScopes(detail interface{}) []string { + m, ok := detail.(map[string]interface{}) + if !ok { + return nil + } + violations, ok := m["permission_violations"].([]interface{}) + if !ok { + return nil + } + var scopes []string + for _, v := range violations { + vm, ok := v.(map[string]interface{}) + if !ok { + continue + } + if subject, ok := vm["subject"].(string); ok { + scopes = append(scopes, subject) + } + } + return scopes +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 00000000..f5668d5e --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,189 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/cmd/api" + "github.com/larksuite/cli/cmd/auth" + cmdconfig "github.com/larksuite/cli/cmd/config" + "github.com/larksuite/cli/cmd/schema" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that +// auth, config, and schema commands have auth check disabled, +// while api does not. +func TestPersistentPreRunE_AuthCheckDisabledAnnotations(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + authCmd := auth.NewCmdAuth(f) + if !cmdutil.IsAuthCheckDisabled(authCmd) { + t.Error("expected auth command to have auth check disabled") + } + + configCmd := cmdconfig.NewCmdConfig(f) + if !cmdutil.IsAuthCheckDisabled(configCmd) { + t.Error("expected config command to have auth check disabled") + } + + schemaCmd := schema.NewCmdSchema(f, nil) + if !cmdutil.IsAuthCheckDisabled(schemaCmd) { + t.Error("expected schema command to have auth check disabled") + } + + apiCmd := api.NewCmdApi(f, nil) + if cmdutil.IsAuthCheckDisabled(apiCmd) { + t.Error("expected api command to NOT have auth check disabled") + } +} + +func TestPersistentPreRunE_AuthSubcommands(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + authCmd := auth.NewCmdAuth(f) + for _, sub := range authCmd.Commands() { + if !cmdutil.IsAuthCheckDisabled(sub) { + t.Errorf("expected auth subcommand %q to inherit disabled auth check", sub.Name()) + } + } +} + +func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + configCmd := cmdconfig.NewCmdConfig(f) + for _, sub := range configCmd.Commands() { + if !cmdutil.IsAuthCheckDisabled(sub) { + t.Errorf("expected config subcommand %q to inherit disabled auth check", sub.Name()) + } + } +} + +func TestHandleRootError_RawError_SkipsEnrichmentAndEnvelope(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + // Create a permission error (would normally be enriched) and mark it Raw + err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "calendar:calendar:readonly"}, + }, + }) + err.Raw = true + + code := handleRootError(f, err) + if code != output.ExitAPI { + t.Errorf("expected exit code %d, got %d", output.ExitAPI, code) + } + // stderr should be empty — no envelope written + if stderr.Len() != 0 { + t.Errorf("expected empty stderr for Raw error, got: %s", stderr.String()) + } + // The message should NOT have been enriched by enrichPermissionError + // (ErrAPI sets "Permission denied [code]" but enrichment would replace it with "App scope not enabled: ...") + if strings.Contains(err.Error(), "App scope not enabled") { + t.Errorf("expected message not enriched, got: %s", err.Error()) + } + // Detail.Detail should be preserved (enrichPermissionError clears it to nil) + if err.Detail != nil && err.Detail.Detail == nil { + t.Error("expected Detail.Detail to be preserved, but it was cleared") + } +} + +func TestHandleRootError_NonRawError_EnrichesAndWritesEnvelope(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + // Create a permission error without Raw — should be enriched + err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "calendar:calendar:readonly"}, + }, + }) + + code := handleRootError(f, err) + if code != output.ExitAPI { + t.Errorf("expected exit code %d, got %d", output.ExitAPI, code) + } + // stderr should contain the error envelope + if stderr.Len() == 0 { + t.Error("expected non-empty stderr for non-Raw error") + } + // The message should have been enriched + if !strings.Contains(err.Error(), "App scope not enabled") { + t.Errorf("expected enriched message, got: %s", err.Error()) + } +} + +func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) { + tests := []struct { + name string + appID string + scope string + wantInURL string // substring that must appear in console_url + denyInURL string // substring that must NOT appear raw in console_url + }{ + { + name: "ampersand in scope", + appID: "cli_good", + scope: "scope&evil=injected", + wantInURL: "scopes=scope%26evil%3Dinjected", + denyInURL: "scopes=scope&evil=injected", + }, + { + name: "hash in scope", + appID: "cli_good", + scope: "scope#fragment", + wantInURL: "scopes=scope%23fragment", + denyInURL: "scopes=scope#fragment", + }, + { + name: "space in scope", + appID: "cli_good", + scope: "scope with spaces", + wantInURL: "scopes=scope+with+spaces", + }, + { + name: "special chars in appID", + appID: "app&id=bad", + scope: "calendar:calendar:readonly", + wantInURL: "clientID=app%26id%3Dbad", + denyInURL: "clientID=app&id=bad", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: tt.appID, AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + exitErr := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "scope not enabled", map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": tt.scope}, + }, + }) + + handleRootError(f, exitErr) + + consoleURL := exitErr.Detail.ConsoleURL + if consoleURL == "" { + t.Fatal("expected console_url to be set") + } + if !strings.Contains(consoleURL, tt.wantInURL) { + t.Errorf("console_url missing expected escaped value\n want substring: %s\n got url: %s", tt.wantInURL, consoleURL) + } + if tt.denyInURL != "" && strings.Contains(consoleURL, tt.denyInURL) { + t.Errorf("console_url contains unescaped dangerous value\n deny substring: %s\n got url: %s", tt.denyInURL, consoleURL) + } + }) + } +} diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go new file mode 100644 index 00000000..81daee13 --- /dev/null +++ b/cmd/schema/schema.go @@ -0,0 +1,500 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/util" + "github.com/spf13/cobra" +) + +// SchemaOptions holds all inputs for the schema command. +type SchemaOptions struct { + Factory *cmdutil.Factory + + // Positional args + Path string + + // Flags + Format string +} + +func printServices(w io.Writer) { + services := registry.ListFromMetaProjects() + fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset) + for _, s := range services { + spec := registry.LoadFromMeta(s) + title := registry.GetStrFromMap(spec, "title") + if title == "" { + title = registry.GetStrFromMap(spec, "description") + } + fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset) + } + fmt.Fprintf(w, "\n%sUsage: lark-cli schema ..%s\n", output.Dim, output.Reset) +} + +func printResourceList(w io.Writer, spec map[string]interface{}) { + name := registry.GetStrFromMap(spec, "name") + version := registry.GetStrFromMap(spec, "version") + title := registry.GetStrFromMap(spec, "title") + if title == "" { + title = registry.GetStrFromMap(spec, "description") + } + servicePath := registry.GetStrFromMap(spec, "servicePath") + + fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title) + fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset) + + resources, _ := spec["resources"].(map[string]interface{}) + for _, resName := range sortedKeys(resources) { + fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset) + resMap, _ := resources[resName].(map[string]interface{}) + methods, _ := resMap["methods"].(map[string]interface{}) + for _, methodName := range sortedKeys(methods) { + m, _ := methods[methodName].(map[string]interface{}) + httpMethod := registry.GetStrFromMap(m, "httpMethod") + desc := registry.GetStrFromMap(m, "description") + danger := "" + if d, _ := m["danger"].(bool); d { + danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset) + } + fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger) + } + fmt.Fprintln(w) + } + fmt.Fprintf(w, "%sUsage: lark-cli schema %s..%s\n", output.Dim, name, output.Reset) +} + +func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) { + servicePath := registry.GetStrFromMap(spec, "servicePath") + specName := registry.GetStrFromMap(spec, "name") + methodPath := registry.GetStrFromMap(method, "path") + fullPath := servicePath + "/" + methodPath + httpMethod := registry.GetStrFromMap(method, "httpMethod") + desc := registry.GetStrFromMap(method, "description") + + fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset) + + httpColor := output.Yellow + if httpMethod == "GET" { + httpColor = output.Green + } else if httpMethod == "DELETE" { + httpColor = output.Red + } + fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath) + if desc != "" { + fmt.Fprintf(w, " %s\n", desc) + } + fmt.Fprintln(w) + + // Parameters + params, _ := method["parameters"].(map[string]interface{}) + if len(params) > 0 { + fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset) + fmt.Fprintf(w, " %s--params%s %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset) + for _, paramName := range sortedParamKeys(params) { + p, _ := params[paramName].(map[string]interface{}) + pType := registry.GetStrFromMap(p, "type") + if pType == "" { + pType = "string" + } + location := registry.GetStrFromMap(p, "location") + required, _ := p["required"].(bool) + reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset) + if required { + reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset) + } + locColor := output.Dim + if location == "path" { + locColor = output.Yellow + } + // Options (enum values) + optStr := formatOptions(p) + fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr) + if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" { + pdesc = util.TruncateStrWithEllipsis(pdesc, 100) + fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset) + } + if ex := registry.GetStrFromMap(p, "example"); ex != "" { + fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset) + } + if rangeStr := formatRange(p); rangeStr != "" { + fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset) + } + } + fmt.Fprintln(w) + } + + // --data for write methods + if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" { + if len(params) == 0 { + fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset) + } + fmt.Fprintf(w, " %s--data%s %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset) + requestBody, _ := method["requestBody"].(map[string]interface{}) + if len(requestBody) > 0 { + printNestedFields(w, requestBody, " ", "") + } + fmt.Fprintln(w) + } + + // Response + responseBody, _ := method["responseBody"].(map[string]interface{}) + if len(responseBody) > 0 { + fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset) + printNestedFields(w, responseBody, " ", "") + fmt.Fprintln(w) + } + + // Identity + if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 { + var identities []string + for _, t := range tokens { + if s, ok := t.(string); ok { + switch s { + case "user": + identities = append(identities, "user") + case "tenant": + identities = append(identities, "bot") + } + } + } + if len(identities) > 0 { + fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", ")) + } + } + + // Scopes (all) + if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 { + var scopeStrs []string + for _, s := range scopes { + if str, ok := s.(string); ok { + scopeStrs = append(scopeStrs, str) + } + } + fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", ")) + } + + // CLI example + fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName) + + // Docs + if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" { + fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl) + } +} + +func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) { + for _, fieldName := range sortedFieldKeys(fields) { + f, _ := fields[fieldName].(map[string]interface{}) + fullName := fieldName + if prefix != "" { + fullName = prefix + "." + fieldName + } + fType := registry.GetStrFromMap(f, "type") + required, _ := f["required"].(bool) + reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset) + if required { + reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset) + } + optStr := formatOptions(f) + fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr) + desc := registry.GetStrFromMap(f, "description") + if desc != "" { + desc = util.TruncateStrWithEllipsis(desc, 100) + fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset) + } + if ex := registry.GetStrFromMap(f, "example"); ex != "" { + fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset) + } + if rangeStr := formatRange(f); rangeStr != "" { + fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset) + } + if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 { + printNestedFields(w, props, indent+" ", fullName) + } + } +} + +// formatOptions returns " — val1 | val2 | ..." if field has options, else "". +func formatOptions(f map[string]interface{}) string { + opts, ok := f["options"].([]interface{}) + if !ok || len(opts) == 0 { + return "" + } + var vals []string + for _, o := range opts { + if om, ok := o.(map[string]interface{}); ok { + if v := registry.GetStrFromMap(om, "value"); v != "" { + vals = append(vals, v) + } + } + } + if len(vals) == 0 { + return "" + } + return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset) +} + +// formatRange returns "min..max" if field has min/max, else "". +func formatRange(f map[string]interface{}) string { + minVal := registry.GetStrFromMap(f, "min") + maxVal := registry.GetStrFromMap(f, "max") + if minVal == "" && maxVal == "" { + return "" + } + if minVal != "" && maxVal != "" { + return minVal + ".." + maxVal + } + if minVal != "" { + return ">=" + minVal + } + return "<=" + maxVal +} + +// sortedKeys returns map keys in alphabetical order. +func sortedKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// sortedParamKeys returns parameter keys sorted: required first, then alphabetical. +func sortedParamKeys(params map[string]interface{}) []string { + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + pi, _ := params[keys[i]].(map[string]interface{}) + pj, _ := params[keys[j]].(map[string]interface{}) + ri, _ := pi["required"].(bool) + rj, _ := pj["required"].(bool) + if ri != rj { + return ri + } + return keys[i] < keys[j] + }) + return keys +} + +// sortedFieldKeys returns field keys sorted: required first, then alphabetical. +func sortedFieldKeys(fields map[string]interface{}) []string { + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + fi, _ := fields[keys[i]].(map[string]interface{}) + fj, _ := fields[keys[j]].(map[string]interface{}) + ri, _ := fi["required"].(bool) + rj, _ := fj["required"].(bool) + if ri != rj { + return ri + } + return keys[i] < keys[j] + }) + return keys +} + +func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) { + for i := len(parts); i >= 1; i-- { + candidateName := strings.Join(parts[:i], ".") + if res, ok := resources[candidateName]; ok { + if resMap, ok := res.(map[string]interface{}); ok { + return resMap, candidateName, parts[i:] + } + } + } + return nil, "", nil +} + +// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook). +func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Command { + opts := &SchemaOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "schema [path]", + Short: "View API method parameters, types, and scopes", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.Path = args[0] + } + if runF != nil { + return runF(opts) + } + return schemaRun(opts) + }, + } + cmdutil.DisableAuthCheck(cmd) + + cmd.ValidArgsFunction = completeSchemaPath + cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") + _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp + }) + + return cmd +} + +// completeSchemaPath provides tab-completion for the schema path argument. +// It handles dotted resource names (e.g. app.table.fields) by iterating all +// resources and classifying each as a prefix-match or fully-matched. +func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + parts := strings.Split(toComplete, ".") + + // Level 1: complete service names + if len(parts) <= 1 { + var completions []string + for _, s := range registry.ListFromMetaProjects() { + if strings.HasPrefix(s, toComplete) { + completions = append(completions, s+".") + } + } + return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } + + serviceName := parts[0] + spec := registry.LoadFromMeta(serviceName) + if spec == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + resources, _ := spec["resources"].(map[string]interface{}) + if resources == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // afterService = everything user typed after "serviceName." + afterService := strings.Join(parts[1:], ".") + + var completions []string + + for resName, resVal := range resources { + if strings.HasPrefix(resName, afterService) { + // afterService is a prefix of this resource name → resource candidate + completions = append(completions, serviceName+"."+resName+".") + } else if strings.HasPrefix(afterService, resName+".") { + // This resource is fully matched; remainder is method prefix + methodPrefix := afterService[len(resName)+1:] + resMap, _ := resVal.(map[string]interface{}) + if resMap == nil { + continue + } + methods, _ := resMap["methods"].(map[string]interface{}) + for methodName := range methods { + if strings.HasPrefix(methodName, methodPrefix) { + completions = append(completions, serviceName+"."+resName+"."+methodName) + } + } + } + } + + sort.Strings(completions) + + // If all completions end with ".", user is still navigating resources → NoSpace + allTrailingDot := len(completions) > 0 + for _, c := range completions { + if !strings.HasSuffix(c, ".") { + allTrailingDot = false + break + } + } + directive := cobra.ShellCompDirectiveNoFileComp + if allTrailingDot { + directive |= cobra.ShellCompDirectiveNoSpace + } + return completions, directive +} + +func schemaRun(opts *SchemaOptions) error { + out := opts.Factory.IOStreams.Out + + if opts.Path == "" { + printServices(out) + return nil + } + + parts := strings.Split(opts.Path, ".") + + serviceName := parts[0] + spec := registry.LoadFromMeta(serviceName) + if spec == nil { + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown service: %s", serviceName), + fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", "))) + } + + if len(parts) == 1 { + if opts.Format == "pretty" { + printResourceList(out, spec) + } else { + output.PrintJson(out, spec) + } + return nil + } + + resources, _ := spec["resources"].(map[string]interface{}) + resource, resName, remaining := findResourceByPath(resources, parts[1:]) + if resource == nil { + var resNames []string + for k := range resources { + resNames = append(resNames, k) + } + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")), + fmt.Sprintf("Available: %s", strings.Join(resNames, ", "))) + } + + if len(remaining) == 0 { + if opts.Format == "pretty" { + fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset) + methods, _ := resource["methods"].(map[string]interface{}) + for _, mName := range sortedKeys(methods) { + m, _ := methods[mName].(map[string]interface{}) + httpMethod := registry.GetStrFromMap(m, "httpMethod") + desc := registry.GetStrFromMap(m, "description") + fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset) + } + fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.%s\n", output.Dim, serviceName, resName, output.Reset) + } else { + output.PrintJson(out, resource) + } + return nil + } + + methodName := remaining[0] + methods, _ := resource["methods"].(map[string]interface{}) + method, ok := methods[methodName].(map[string]interface{}) + if !ok { + var mNames []string + for k := range methods { + mNames = append(mNames, k) + } + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName), + fmt.Sprintf("Available: %s", strings.Join(mNames, ", "))) + } + + if opts.Format == "pretty" { + printMethodDetail(out, spec, resName, methodName, method) + } else { + output.PrintJson(out, method) + } + return nil +} diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go new file mode 100644 index 00000000..2f36159a --- /dev/null +++ b/cmd/schema/schema_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func TestSchemaCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *SchemaOptions + cmd := NewCmdSchema(f, func(opts *SchemaOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Path != "calendar.events.list" { + t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path) + } + if gotOpts.Format != "pretty" { + t.Errorf("expected Format=pretty, got %s", gotOpts.Format) + } +} + +func TestSchemaCmd_NoArgs(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "Available services") { + t.Error("expected service list output") + } +} + +func TestSchemaCmd_UnknownService(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"nonexistent_service"}) + err := cmd.Execute() + if err == nil { + t.Error("expected error for unknown service") + } + if !strings.Contains(err.Error(), "Unknown service") { + t.Errorf("expected 'Unknown service' error, got: %v", err) + } +} diff --git a/cmd/service/service.go b/cmd/service/service.go new file mode 100644 index 00000000..e3fceded --- /dev/null +++ b/cmd/service/service.go @@ -0,0 +1,432 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/spf13/cobra" +) + +// RegisterServiceCommands registers all service commands from from_meta specs. +func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) { + for _, project := range registry.ListFromMetaProjects() { + spec := registry.LoadFromMeta(project) + if spec == nil { + continue + } + specName := registry.GetStrFromMap(spec, "name") + servicePath := registry.GetStrFromMap(spec, "servicePath") + if specName == "" || servicePath == "" { + continue + } + resources, _ := spec["resources"].(map[string]interface{}) + if resources == nil { + continue + } + registerService(parent, spec, resources, f) + } +} + +func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) { + specName := registry.GetStrFromMap(spec, "name") + specDesc := registry.GetServiceDescription(specName, "en") + if specDesc == "" { + specDesc = registry.GetStrFromMap(spec, "description") + } + + // Find existing service command or create one + var svc *cobra.Command + for _, c := range parent.Commands() { + if c.Name() == specName { + svc = c + break + } + } + if svc == nil { + svc = &cobra.Command{ + Use: specName, + Short: specDesc, + } + parent.AddCommand(svc) + } + + for resName, resource := range resources { + resMap, _ := resource.(map[string]interface{}) + if resMap == nil { + continue + } + registerResource(svc, spec, resName, resMap, f) + } +} + +func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) { + res := &cobra.Command{ + Use: name, + Short: name + " operations", + } + parent.AddCommand(res) + + methods, _ := resource["methods"].(map[string]interface{}) + for methodName, method := range methods { + methodMap, _ := method.(map[string]interface{}) + if methodMap == nil { + continue + } + registerMethod(res, spec, methodMap, methodName, name, f) + } +} + +// ServiceMethodOptions holds all inputs for a dynamically registered service method command. +type ServiceMethodOptions struct { + Factory *cmdutil.Factory + Cmd *cobra.Command + Ctx context.Context + Spec map[string]interface{} + Method map[string]interface{} + SchemaPath string + + // Flags + Params string + Data string + As core.Identity + Output string + PageAll bool + PageLimit int + PageDelay int + Format string + DryRun bool +} + +func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) { + parent.AddCommand(NewCmdServiceMethod(f, spec, method, name, resName, nil)) +} + +// NewCmdServiceMethod creates a command for a dynamically registered service method. +func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command { + desc := registry.GetStrFromMap(method, "description") + httpMethod := registry.GetStrFromMap(method, "httpMethod") + specName := registry.GetStrFromMap(spec, "name") + schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name) + + opts := &ServiceMethodOptions{ + Factory: f, + Spec: spec, + Method: method, + SchemaPath: schemaPath, + } + var asStr string + + cmd := &cobra.Command{ + Use: name, + Short: desc, + Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Cmd = cmd + opts.Ctx = cmd.Context() + opts.As = core.Identity(asStr) + if runF != nil { + return runF(opts) + } + return serviceMethodRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON") + switch httpMethod { + case "POST", "PUT", "PATCH", "DELETE": + cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON") + } + cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)") + cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") + cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") + cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") + cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") + cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") + + _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp + }) + _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp + }) + + cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips")) + + return cmd +} + +func serviceMethodRun(opts *ServiceMethodOptions) error { + f := opts.Factory + opts.As = f.ResolveAs(opts.Cmd, opts.As) + + // Check if this API method supports the resolved identity. + if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 { + if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil { + return err + } + } + + if opts.PageAll && opts.Output != "" { + return output.ErrValidation("--output and --page-all are mutually exclusive") + } + + config, err := f.ResolveConfig(opts.As) + if err != nil { + return err + } + // Identity info is now included in the JSON envelope; skip stderr printing. + // cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected) + + scopes, _ := opts.Method["scopes"].([]interface{}) + if !opts.As.IsBot() { + if err := checkServiceScopes(config, opts.Method, scopes); err != nil { + return err + } + } + + request, err := buildServiceRequest(opts) + if err != nil { + return err + } + + if opts.DryRun { + return serviceDryRun(f, request, config, opts.Format) + } + + ac, err := f.NewAPIClientWithConfig(config) + if err != nil { + return err + } + + out := f.IOStreams.Out + format, formatOK := output.ParseFormat(opts.Format) + if !formatOK { + fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format) + } + + checkErr := scopeAwareChecker(scopes, opts.As.IsBot()) + + if opts.PageAll { + return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut, + client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr) + } + + resp, err := ac.DoAPI(opts.Ctx, request) + if err != nil { + return output.ErrNetwork("API call failed: %s", err) + } + return client.HandleResponse(resp, client.ResponseOptions{ + OutputPath: opts.Output, + Format: format, + Out: out, + ErrOut: f.IOStreams.ErrOut, + CheckError: checkErr, + }) +} + +// checkServiceScopes pre-checks user scopes before making the API call. +func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error { + requiredScopes, hasRequired := method["requiredScopes"].([]interface{}) + + if hasRequired && len(requiredScopes) > 0 { + // Strict: ALL requiredScopes must be present + stored := auth.GetStoredToken(config.AppID, config.UserOpenId) + if stored != nil { + required := make([]string, 0, len(requiredScopes)) + for _, s := range requiredScopes { + if str, ok := s.(string); ok { + required = append(required, str) + } + } + if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { + return output.ErrWithHint(output.ExitAuth, "missing_scope", + fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), + fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) + } + } + return nil + } + + if len(scopes) == 0 { + return nil + } + + // Default: ANY one of the declared scopes is sufficient + stored := auth.GetStoredToken(config.AppID, config.UserOpenId) + if stored == nil { + return nil + } + grantedScopes := make(map[string]bool) + for _, s := range strings.Fields(stored.Scope) { + grantedScopes[s] = true + } + for _, s := range scopes { + if str, ok := s.(string); ok && grantedScopes[str] { + return nil + } + } + recommended := registry.SelectRecommendedScope(scopes, "user") + return output.ErrWithHint(output.ExitAPI, "permission", + fmt.Sprintf("insufficient permissions (required scope: %s)", recommended), + fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended)) +} + +// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest. +func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, error) { + spec := opts.Spec + method := opts.Method + schemaPath := opts.SchemaPath + httpMethod := registry.GetStrFromMap(method, "httpMethod") + + var params map[string]interface{} + if opts.Params != "" { + if err := json.Unmarshal([]byte(opts.Params), ¶ms); err != nil { + return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format") + } + } else { + params = map[string]interface{}{} + } + + url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path") + + parameters, _ := method["parameters"].(map[string]interface{}) + for name, param := range parameters { + p, _ := param.(map[string]interface{}) + if registry.GetStrFromMap(p, "location") != "path" { + continue + } + val, ok := params[name] + if !ok || util.IsEmptyValue(val) { + return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("missing required path parameter: %s", name), + fmt.Sprintf("lark-cli schema %s", schemaPath)) + } + valStr := fmt.Sprintf("%v", val) + if err := validate.ResourceName(valStr, name); err != nil { + return client.RawApiRequest{}, output.ErrValidation("%s", err) + } + url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1) + delete(params, name) + } + + queryParams := map[string]interface{}{} + for name, param := range parameters { + p, _ := param.(map[string]interface{}) + if registry.GetStrFromMap(p, "location") != "query" { + continue + } + value, exists := params[name] + required, _ := p["required"].(bool) + isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size") + if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) { + return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("missing required query parameter: %s", name), + fmt.Sprintf("lark-cli schema %s", schemaPath)) + } + if exists && !util.IsEmptyValue(value) { + queryParams[name] = value + } + } + for name, value := range params { + if _, ok := queryParams[name]; !ok { + queryParams[name] = value + } + } + + data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data) + if err != nil { + return client.RawApiRequest{}, err + } + + request := client.RawApiRequest{ + Method: httpMethod, + URL: url, + Params: queryParams, + Data: data, + As: opts.As, + } + if opts.Output != "" { + request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload()) + } + return request, nil +} + +func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error { + return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format) +} + +// scopeAwareChecker returns an error checker that enriches scope-related errors with login hints. +func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) error { + return func(result interface{}) error { + resultMap, ok := result.(map[string]interface{}) + if !ok || resultMap == nil { + return nil + } + code, _ := util.ToFloat64(resultMap["code"]) + if code == 0 { + return nil + } + larkCode := int(code) + msg := registry.GetStrFromMap(resultMap, "msg") + + if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 { + identity := "user" + if isBotMode { + identity = "tenant" + } + recommended := registry.SelectRecommendedScope(scopes, identity) + return output.ErrWithHint(output.ExitAPI, "permission", + fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg), + fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended)) + } + + return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"]) + } +} + +func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error { + switch format { + case output.FormatNDJSON, output.FormatTable, output.FormatCSV: + pf := output.NewPaginatedFormatter(out, format) + result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) { + pf.FormatPage(items) + }, pagOpts) + if err != nil { + return output.ErrNetwork("API call failed: %s", err) + } + if apiErr := checkErr(result); apiErr != nil { + return apiErr + } + if !hasItems { + fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format) + output.FormatValue(out, result, output.FormatJSON) + } + return nil + default: + result, err := ac.PaginateAll(ctx, request, pagOpts) + if err != nil { + return output.ErrNetwork("API call failed: %s", err) + } + if apiErr := checkErr(result); apiErr != nil { + return apiErr + } + output.FormatValue(out, result, format) + return nil + } +} diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go new file mode 100644 index 00000000..55c0986d --- /dev/null +++ b/cmd/service/service_test.go @@ -0,0 +1,552 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// ── helpers ── + +var testConfig = &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, +} + +func driveSpec() map[string]interface{} { + return map[string]interface{}{ + "name": "drive", + "servicePath": "/open-apis/drive/v1", + } +} + +func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} { + m := map[string]interface{}{ + "path": "files/{file_token}/copy", + "httpMethod": httpMethod, + } + if params != nil { + m["parameters"] = params + } else { + m["parameters"] = map[string]interface{}{ + "file_token": map[string]interface{}{ + "type": "string", "location": "path", "required": true, + }, + } + } + return m +} + +func tokenStub() *httpmock.Stub { + return &httpmock.Stub{ + URL: "tenant_access_token", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test", "expire": 7200, + }, + } +} + +// ── registerService ── + +func TestRegisterService(t *testing.T) { + parent := &cobra.Command{Use: "root"} + f := &cmdutil.Factory{} + spec := map[string]interface{}{ + "name": "base", + "description": "Base API", + "servicePath": "/open-apis/base/v3", + } + resources := map[string]interface{}{ + "tables": map[string]interface{}{ + "methods": map[string]interface{}{ + "list": map[string]interface{}{ + "description": "List tables", + "httpMethod": "GET", + }, + }, + }, + } + + registerService(parent, spec, resources, f) + + // service command exists + svc, _, err := parent.Find([]string{"base"}) + if err != nil || svc.Name() != "base" { + t.Fatalf("expected 'base' command, got err=%v", err) + } + // resource sub-command + res, _, err := parent.Find([]string{"base", "tables"}) + if err != nil || res.Name() != "tables" { + t.Fatalf("expected 'tables' command, got err=%v", err) + } + // method sub-command + meth, _, err := parent.Find([]string{"base", "tables", "list"}) + if err != nil || meth.Name() != "list" { + t.Fatalf("expected 'list' command, got err=%v", err) + } +} + +func TestRegisterService_MergesExistingCommand(t *testing.T) { + parent := &cobra.Command{Use: "root"} + existing := &cobra.Command{Use: "base", Short: "existing"} + parent.AddCommand(existing) + + f := &cmdutil.Factory{} + spec := map[string]interface{}{ + "name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3", + } + resources := map[string]interface{}{ + "tables": map[string]interface{}{ + "methods": map[string]interface{}{ + "list": map[string]interface{}{"description": "List", "httpMethod": "GET"}, + }, + }, + } + + registerService(parent, spec, resources, f) + + // Should reuse existing, not duplicate + count := 0 + for _, c := range parent.Commands() { + if c.Name() == "base" { + count++ + } + } + if count != 1 { + t.Errorf("expected 1 'base' command, got %d", count) + } + // Resource should be added under the existing command + _, _, err := parent.Find([]string{"base", "tables", "list"}) + if err != nil { + t.Fatalf("expected 'list' under existing 'base' command, got err=%v", err) + } +} + +// ── NewCmdServiceMethod flags ── + +func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) { + f := &cmdutil.Factory{} + cmd := NewCmdServiceMethod(f, driveSpec(), + map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil) + + if cmd.Flags().Lookup("data") != nil { + t.Error("GET method should not have --data flag") + } + if cmd.Use != "list" { + t.Errorf("expected Use=list, got %s", cmd.Use) + } + if !strings.Contains(cmd.Long, "schema drive.files.list") { + t.Errorf("expected schema path in Long, got %s", cmd.Long) + } +} + +func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) { + f := &cmdutil.Factory{} + cmd := NewCmdServiceMethod(f, driveSpec(), + map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil) + + if cmd.Flags().Lookup("data") == nil { + t.Error("POST method should have --data flag") + } +} + +func TestNewCmdServiceMethod_RunFCallback(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + + var captured *ServiceMethodOptions + cmd := NewCmdServiceMethod(f, driveSpec(), + map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", + func(opts *ServiceMethodOptions) error { + captured = opts + return nil + }) + cmd.SetArgs([]string{"--as", "bot"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured == nil { + t.Fatal("runF was not called") + } + if captured.As != core.AsBot { + t.Errorf("expected As=bot, got %s", captured.As) + } + if captured.SchemaPath != "drive.files.list" { + t.Errorf("expected SchemaPath=drive.files.list, got %s", captured.SchemaPath) + } +} + +// ── dry-run / buildServiceRequest ── + +func TestServiceMethod_DryRun_PathParam(t *testing.T) { + tests := []struct { + name string + fileToken string + wantInURL string + }{ + {"normal token", "boxcn123abc", "/open-apis/drive/v1/files/boxcn123abc/copy"}, + {"hyphen and underscore", "ou_abc-123_def", "/open-apis/drive/v1/files/ou_abc-123_def/copy"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil) + cmd.SetArgs([]string{ + "--params", `{"file_token":"` + tt.fileToken + `"}`, + "--data", `{"name":"test.txt"}`, + "--dry-run", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), tt.wantInURL) { + t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String()) + } + }) + } +} + +func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) { + tests := []struct { + name string + fileToken string + wantErr string + }{ + {"path traversal with slashes", "../../auth/v3/token", "path traversal"}, + {"single dot-dot", "../admin", "path traversal"}, + {"question mark injection", "token?evil=true", "invalid characters"}, + {"hash injection", "token#fragment", "invalid characters"}, + {"percent-encoded bypass", "token%2F..%2Fadmin", "invalid characters"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil) + cmd.SetArgs([]string{ + "--params", `{"file_token":"` + tt.fileToken + `"}`, + "--data", `{"name":"test.txt"}`, + "--dry-run", + }) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for malicious path parameter") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got: %v", tt.wantErr, err) + } + }) + } +} + +func TestServiceMethod_MissingPathParam(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil) + cmd.SetArgs([]string{"--params", `{}`, "--data", `{}`, "--dry-run"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for missing path param") + } + if !strings.Contains(err.Error(), "missing required path parameter") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) { + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{ + "path": "items", "httpMethod": "GET", + "parameters": map[string]interface{}{ + "q": map[string]interface{}{"location": "query", "required": true}, + }, + } + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--params", `{}`, "--dry-run"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for missing required query param") + } + if !strings.Contains(err.Error(), "missing required query parameter: q") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) { + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{ + "path": "items", "httpMethod": "GET", + "parameters": map[string]interface{}{ + "page_size": map[string]interface{}{"location": "query", "required": true}, + }, + } + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err) + } + if !strings.Contains(stdout.String(), "Dry Run") { + t.Error("expected dry-run output") + } +} + +func TestServiceMethod_InvalidParamsJSON(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--params", "{bad", "--dry-run"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid JSON") + } + if !strings.Contains(err.Error(), "--params invalid JSON format") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestServiceMethod_InvalidDataJSON(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil) + cmd.SetArgs([]string{"--data", "{bad", "--dry-run"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid --data JSON") + } + if !strings.Contains(err.Error(), "--data invalid JSON format") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --output + --page-all conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("unexpected error: %v", err) + } +} + +// ── bot mode integration with httpmock ── + +func TestServiceMethod_BotMode_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, testConfig) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"result": "success"}, + }, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "success") { + t.Errorf("expected 'success' in output, got:\n%s", stdout.String()) + } +} + +func TestServiceMethod_BotMode_APIError(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu, + }) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{"code": 40003, "msg": "invalid token"}, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected API error") + } + var exitErr *output.ExitError + if !isExitError(err, &exitErr) { + t.Fatalf("expected ExitError, got: %T %v", err, err) + } + if exitErr.Code != output.ExitAPI { + t.Errorf("expected ExitAPI code, got %d", exitErr.Code) + } + // stdout must be empty on API error — error details belong in stderr envelope only. + // This guards against re-introducing duplicate output (see commit 86215a10). + if stdout.Len() > 0 { + t.Errorf("expected no stdout on API error, got: %s", stdout.String()) + } +} + +func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu, + }) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "1"}}, + "has_more": false, + }, + }, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot", "--page-all"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"id"`) { + t.Errorf("expected items in output, got:\n%s", stdout.String()) + } +} + +func TestServiceMethod_UnknownFormat_Warning(t *testing.T) { + f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu, + }) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stderr.String(), "warning: unknown format") { + t.Errorf("expected format warning in stderr, got:\n%s", stderr.String()) + } +} + +// ── scopeAwareChecker ── + +func TestScopeAwareChecker_Success(t *testing.T) { + checker := scopeAwareChecker(nil, false) + err := checker(map[string]interface{}{"code": 0.0, "msg": "ok"}) + if err != nil { + t.Errorf("expected nil error for code=0, got: %v", err) + } +} + +func TestScopeAwareChecker_NonMapResult(t *testing.T) { + checker := scopeAwareChecker(nil, false) + err := checker("not a map") + if err != nil { + t.Errorf("expected nil for non-map result, got: %v", err) + } +} + +func TestScopeAwareChecker_APIError(t *testing.T) { + checker := scopeAwareChecker(nil, false) + err := checker(map[string]interface{}{"code": 40003.0, "msg": "bad request"}) + if err == nil { + t.Fatal("expected error for non-zero code") + } + if !strings.Contains(err.Error(), "API error: [40003]") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestScopeAwareChecker_ScopeError_UserMode(t *testing.T) { + scopes := []interface{}{"calendar:read"} + checker := scopeAwareChecker(scopes, false) + err := checker(map[string]interface{}{ + "code": float64(output.LarkErrUserScopeInsufficient), + "msg": "scope insufficient", + }) + if err == nil { + t.Fatal("expected permission error") + } + var exitErr *output.ExitError + if !isExitError(err, &exitErr) { + t.Fatalf("expected ExitError, got %T", err) + } + if exitErr.Detail.Type != "permission" { + t.Errorf("expected type=permission, got %s", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Hint, "auth login") { + t.Errorf("expected auth login hint, got %s", exitErr.Detail.Hint) + } +} + +func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) { + scopes := []interface{}{"calendar:read"} + checker := scopeAwareChecker(scopes, true) + err := checker(map[string]interface{}{ + "code": float64(output.LarkErrUserScopeInsufficient), + "msg": "scope insufficient", + }) + if err == nil { + t.Fatal("expected permission error") + } + // Bot mode should still include the scope hint + if !strings.Contains(err.Error(), "insufficient permissions") { + t.Errorf("unexpected error: %v", err) + } +} + +// ── helpers ── + +func isExitError(err error, target **output.ExitError) bool { + ee, ok := err.(*output.ExitError) + if ok && target != nil { + *target = ee + } + return ok +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..ed41d1f0 --- /dev/null +++ b/go.mod @@ -0,0 +1,54 @@ +module github.com/larksuite/cli + +go 1.23.0 + +require ( + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/gofrs/flock v0.8.1 + github.com/google/uuid v1.6.0 + github.com/larksuite/oapi-sdk-go/v3 v3.5.3 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/smartystreets/goconvey v1.8.1 + github.com/spf13/cobra v1.10.2 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/net v0.33.0 + golang.org/x/sys v0.33.0 + golang.org/x/term v0.27.0 + golang.org/x/text v0.23.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/smarty/assertions v1.15.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..b6a807a3 --- /dev/null +++ b/go.sum @@ -0,0 +1,155 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= +github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/app_registration.go b/internal/auth/app_registration.go new file mode 100644 index 00000000..819863d6 --- /dev/null +++ b/internal/auth/app_registration.go @@ -0,0 +1,225 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/larksuite/cli/internal/core" +) + +// AppRegistrationResponse is the response from the app registration begin endpoint. +type AppRegistrationResponse struct { + DeviceCode string + UserCode string + VerificationUri string + VerificationUriComplete string + ExpiresIn int + Interval int +} + +// AppRegistrationResult is the result of a successful app registration poll. +type AppRegistrationResult struct { + ClientID string + ClientSecret string + UserInfo *AppRegUserInfo +} + +// AppRegUserInfo contains user info returned from app registration. +type AppRegUserInfo struct { + OpenID string + TenantBrand string // "feishu" or "lark" +} + +// RequestAppRegistration initiates the app registration device flow. +func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) { + if errOut == nil { + errOut = io.Discard + } + + ep := core.ResolveEndpoints(brand) + regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu + endpoint := regEp.Accounts + "/oauth/v1/app/registration" + + form := url.Values{} + form.Set("action", "begin") + form.Set("archetype", "PersonalAgent") + form.Set("auth_method", "client_secret") + form.Set("request_user_info", "open_id tenant_brand") + + req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("app registration failed: read body: %v", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("app registration failed: HTTP %d – response not JSON", resp.StatusCode) + } + + _, hasError := data["error"] + if resp.StatusCode >= 400 || hasError { + msg := getStr(data, "error_description") + if msg == "" { + msg = getStr(data, "error") + } + if msg == "" { + msg = "Unknown error" + } + return nil, fmt.Errorf("app registration failed: %s", msg) + } + + expiresIn := getInt(data, "expires_in", 300) + interval := getInt(data, "interval", 5) + + userCode := getStr(data, "user_code") + verificationUri := getStr(data, "verification_uri") + verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode) + + return &AppRegistrationResponse{ + DeviceCode: getStr(data, "device_code"), + UserCode: getStr(data, "user_code"), + VerificationUri: verificationUri, + VerificationUriComplete: verificationUriComplete, + ExpiresIn: expiresIn, + Interval: interval, + }, nil +} + +// BuildVerificationURL appends CLI tracking parameters to the verification URL. +func BuildVerificationURL(baseURL, cliVersion string) string { + sep := "&" + if !strings.Contains(baseURL, "?") { + sep = "?" + } + return baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) + + "&ocv=" + url.QueryEscape(cliVersion) + + "&from=cli" +} + +// PollAppRegistration polls the app registration endpoint until the app is created or the flow times out. +// If the result has ClientSecret == "" and UserInfo.TenantBrand == "lark", the caller should +// retry with BrandLark to get the secret from accounts.larksuite.com. +func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) (*AppRegistrationResult, error) { + if errOut == nil { + errOut = io.Discard + } + + const maxPollInterval = 60 + const maxPollAttempts = 200 + + ep := core.ResolveEndpoints(brand) + endpoint := ep.Accounts + "/oauth/v1/app/registration" + deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) + currentInterval := interval + attempts := 0 + + for time.Now().Before(deadline) && attempts < maxPollAttempts { + attempts++ + if ctx.Err() != nil { + return nil, fmt.Errorf("polling was cancelled") + } + + select { + case <-time.After(time.Duration(currentInterval) * time.Second): + case <-ctx.Done(): + return nil, fmt.Errorf("polling was cancelled") + } + + form := url.Values{} + form.Set("action", "poll") + form.Set("device_code", deviceCode) + + req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode())) + if err != nil { + continue + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll network error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll read error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll parse error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + errStr := getStr(data, "error") + + // Success: client_id present + if errStr == "" && getStr(data, "client_id") != "" { + result := &AppRegistrationResult{ + ClientID: getStr(data, "client_id"), + ClientSecret: getStr(data, "client_secret"), + } + if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok { + result.UserInfo = &AppRegUserInfo{ + OpenID: getStr(userInfoRaw, "open_id"), + TenantBrand: getStr(userInfoRaw, "tenant_brand"), + } + } + return result, nil + } + + switch errStr { + case "authorization_pending": + continue + case "slow_down": + currentInterval = minInt(currentInterval+5, maxPollInterval) + fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval) + continue + case "access_denied": + return nil, fmt.Errorf("app registration denied by user") + case "expired_token", "invalid_grant": + return nil, fmt.Errorf("device code expired, please try again") + } + + desc := getStr(data, "error_description") + if desc == "" { + desc = errStr + } + if desc == "" { + desc = "Unknown error" + } + return nil, fmt.Errorf("app registration failed: %s", desc) + } + + if attempts >= maxPollAttempts { + fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts) + } + return nil, fmt.Errorf("app registration timed out, please try again") +} diff --git a/internal/auth/app_registration_test.go b/internal/auth/app_registration_test.go new file mode 100644 index 00000000..e706a862 --- /dev/null +++ b/internal/auth/app_registration_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "github.com/smartystreets/goconvey/convey" +) + +func Test_BuildVerificationURL(t *testing.T) { + t.Run("URL不含问号则添加?分隔符", func(t *testing.T) { + result := BuildVerificationURL("https://example.com/verify", "1.0.0") + convey.Convey("should add ? separator", t, func() { + convey.So(result, convey.ShouldContainSubstring, "?lpv=1.0.0") + convey.So(result, convey.ShouldContainSubstring, "&ocv=1.0.0") + convey.So(result, convey.ShouldContainSubstring, "&from=cli") + convey.So(result, convey.ShouldStartWith, "https://example.com/verify?") + }) + }) + + t.Run("URL已含问号则添加&分隔符", func(t *testing.T) { + result := BuildVerificationURL("https://example.com/verify?code=abc", "2.0.0") + convey.Convey("should add & separator", t, func() { + convey.So(result, convey.ShouldContainSubstring, "&lpv=2.0.0") + convey.So(result, convey.ShouldContainSubstring, "&ocv=2.0.0") + convey.So(result, convey.ShouldContainSubstring, "&from=cli") + convey.So(result, convey.ShouldNotContainSubstring, "?lpv=") + }) + }) +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go new file mode 100644 index 00000000..c79aaa22 --- /dev/null +++ b/internal/auth/device_flow.go @@ -0,0 +1,287 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/larksuite/cli/internal/core" +) + +// DeviceAuthResponse is the response from the device authorization endpoint. +type DeviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationUri string `json:"verification_uri"` + VerificationUriComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// DeviceFlowTokenData contains the token data from a successful device flow. +type DeviceFlowTokenData struct { + AccessToken string + RefreshToken string + ExpiresIn int + RefreshExpiresIn int + Scope string +} + +// DeviceFlowResult is the result of polling the token endpoint. +type DeviceFlowResult struct { + OK bool + Token *DeviceFlowTokenData + Error string + Message string +} + +// OAuthEndpoints contains the OAuth endpoint URLs. +type OAuthEndpoints struct { + DeviceAuthorization string + Token string +} + +// ResolveOAuthEndpoints resolves OAuth endpoint URLs based on brand. +func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints { + ep := core.ResolveEndpoints(brand) + return OAuthEndpoints{ + DeviceAuthorization: ep.Accounts + "/oauth/v1/device_authorization", + Token: ep.Open + "/open-apis/authen/v2/oauth/token", + } +} + +// RequestDeviceAuthorization requests a device authorization code. +func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) { + if errOut == nil { + errOut = io.Discard + } + + endpoints := ResolveOAuthEndpoints(brand) + + if !strings.Contains(scope, "offline_access") { + if scope != "" { + scope = scope + " offline_access" + } else { + scope = "offline_access" + } + } + + basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret)) + + form := url.Values{} + form.Set("client_id", appId) + form.Set("scope", scope) + + req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("Device authorization failed: read body: %v", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("Device authorization failed: HTTP %d – response not JSON", resp.StatusCode) + } + + _, hasError := data["error"] + if resp.StatusCode >= 400 || hasError { + msg := getStr(data, "error_description") + if msg == "" { + msg = getStr(data, "error") + } + if msg == "" { + msg = "Unknown error" + } + return nil, fmt.Errorf("Device authorization failed: %s", msg) + } + + expiresIn := getInt(data, "expires_in", 240) + interval := getInt(data, "interval", 5) + + verificationUri := getStr(data, "verification_uri") + verificationUriComplete := getStr(data, "verification_uri_complete") + if verificationUriComplete == "" { + verificationUriComplete = verificationUri + } + + return &DeviceAuthResponse{ + DeviceCode: getStr(data, "device_code"), + UserCode: getStr(data, "user_code"), + VerificationUri: verificationUri, + VerificationUriComplete: verificationUriComplete, + ExpiresIn: expiresIn, + Interval: interval, + }, nil +} + +// PollDeviceToken polls the token endpoint until authorization completes or times out. +func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult { + if errOut == nil { + errOut = io.Discard + } + + const maxPollInterval = 60 + const maxPollAttempts = 200 + + endpoints := ResolveOAuthEndpoints(brand) + deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) + currentInterval := interval + attempts := 0 + + for time.Now().Before(deadline) && attempts < maxPollAttempts { + attempts++ + if ctx.Err() != nil { + return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"} + } + + select { + case <-time.After(time.Duration(currentInterval) * time.Second): + case <-ctx.Done(): + return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"} + } + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + form.Set("device_code", deviceCode) + form.Set("client_id", appId) + form.Set("client_secret", appSecret) + + req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode())) + if err != nil { + continue + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll network error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll read error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll parse error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + errStr := getStr(data, "error") + + if errStr == "" && getStr(data, "access_token") != "" { + fmt.Fprintf(errOut, "[lark-cli] device-flow: token obtained successfully\n") + refreshToken := getStr(data, "refresh_token") + tokenExpiresIn := getInt(data, "expires_in", 7200) + refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800) + if refreshToken == "" { + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: no refresh_token in response\n") + refreshExpiresIn = tokenExpiresIn + } + return &DeviceFlowResult{ + OK: true, + Token: &DeviceFlowTokenData{ + AccessToken: getStr(data, "access_token"), + RefreshToken: refreshToken, + ExpiresIn: tokenExpiresIn, + RefreshExpiresIn: refreshExpiresIn, + Scope: getStr(data, "scope"), + }, + } + } + + switch errStr { + case "authorization_pending": + continue + case "slow_down": + currentInterval = minInt(currentInterval+5, maxPollInterval) + fmt.Fprintf(errOut, "[lark-cli] device-flow: slow_down, interval increased to %ds\n", currentInterval) + continue + case "access_denied": + msg := getStr(data, "error_description") + if msg == "" { + msg = "Authorization denied by user" + } + return &DeviceFlowResult{OK: false, Error: "access_denied", Message: msg} + case "expired_token", "invalid_grant": + msg := getStr(data, "error_description") + if msg == "" { + msg = "Device code expired, please try again" + } + return &DeviceFlowResult{OK: false, Error: "expired_token", Message: msg} + } + + desc := getStr(data, "error_description") + if desc == "" { + desc = errStr + } + if desc == "" { + desc = "Unknown error" + } + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: unexpected error: error=%s, desc=%s\n", errStr, desc) + return &DeviceFlowResult{OK: false, Error: "expired_token", Message: desc} + } + + if attempts >= maxPollAttempts { + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: max poll attempts (%d) reached\n", maxPollAttempts) + } + return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Authorization timed out, please try again"} +} + +// helpers + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func getStr(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func getInt(m map[string]interface{}, key string, fallback int) int { + if v, ok := m[key]; ok { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + } + } + return fallback +} diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go new file mode 100644 index 00000000..3cd5dad7 --- /dev/null +++ b/internal/auth/device_flow_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" +) + +func TestResolveOAuthEndpoints_Feishu(t *testing.T) { + ep := ResolveOAuthEndpoints(core.BrandFeishu) + if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" { + t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) + } + if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" { + t.Errorf("Token = %q", ep.Token) + } +} + +func TestResolveOAuthEndpoints_Lark(t *testing.T) { + ep := ResolveOAuthEndpoints(core.BrandLark) + if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" { + t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) + } + if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" { + t.Errorf("Token = %q", ep.Token) + } +} diff --git a/internal/auth/errors.go b/internal/auth/errors.go new file mode 100644 index 00000000..76bd5995 --- /dev/null +++ b/internal/auth/errors.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + + "github.com/larksuite/cli/internal/output" +) + +const ( + LarkErrBlockByPolicy = 21001 // access denied by access control policy + LarkErrBlockByPolicyTryAuth = 21000 // access denied by access control policy; challenge is required to be completed by user in order to gain access +) + +// RefreshTokenRetryable contains error codes that allow one immediate retry. +// All other refresh errors clear the token immediately. +var RefreshTokenRetryable = map[int]bool{ + output.LarkErrRefreshServerError: true, +} + +// TokenRetryCodes contains error codes that allow retry after token refresh. +var TokenRetryCodes = map[int]bool{ + output.LarkErrTokenInvalid: true, + output.LarkErrTokenExpired: true, +} + +// NeedAuthorizationError is thrown when no valid UAT exists. +type NeedAuthorizationError struct { + UserOpenId string +} + +func (e *NeedAuthorizationError) Error() string { + return fmt.Sprintf("need_user_authorization (user: %s)", e.UserOpenId) +} + +// SecurityPolicyError is returned when a request is blocked by access control policies. +type SecurityPolicyError struct { + Code int + Message string + ChallengeURL string + CLIHint string + Err error +} + +func (e *SecurityPolicyError) Error() string { + if e.Err != nil { + return fmt.Sprintf("security policy error [%d]: %s: %v", e.Code, e.Message, e.Err) + } + return fmt.Sprintf("security policy error [%d]: %s", e.Code, e.Message) +} + +func (e *SecurityPolicyError) Unwrap() error { + return e.Err +} diff --git a/internal/auth/scope.go b/internal/auth/scope.go new file mode 100644 index 00000000..6908012b --- /dev/null +++ b/internal/auth/scope.go @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import "strings" + +// MissingScopes returns the elements of required that are absent from storedScope. +// storedScope is a space-separated list of granted scope strings (as stored in the token). +func MissingScopes(storedScope string, required []string) []string { + granted := make(map[string]bool) + for _, s := range strings.Fields(storedScope) { + granted[s] = true + } + var missing []string + for _, s := range required { + if !granted[s] { + missing = append(missing, s) + } + } + return missing +} diff --git a/internal/auth/scope_test.go b/internal/auth/scope_test.go new file mode 100644 index 00000000..b58d0b98 --- /dev/null +++ b/internal/auth/scope_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" +) + +func TestMissingScopes(t *testing.T) { + tests := []struct { + name string + storedScope string + required []string + expected []string + }{ + { + name: "all matched", + storedScope: "a b c", + required: []string{"a", "b"}, + expected: nil, + }, + { + name: "partial missing", + storedScope: "a b", + required: []string{"a", "c"}, + expected: []string{"c"}, + }, + { + name: "all missing", + storedScope: "a b", + required: []string{"x", "y"}, + expected: []string{"x", "y"}, + }, + { + name: "empty storedScope", + storedScope: "", + required: []string{"a"}, + expected: []string{"a"}, + }, + { + name: "empty required", + storedScope: "a b", + required: []string{}, + expected: nil, + }, + { + name: "extra whitespace in storedScope", + storedScope: " a b c ", + required: []string{"b"}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MissingScopes(tt.storedScope, tt.required) + if !sliceEqual(got, tt.expected) { + t.Errorf("MissingScopes(%q, %v) = %v, want %v", tt.storedScope, tt.required, got, tt.expected) + } + }) + } +} + +func sliceEqual(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/auth/token_store.go b/internal/auth/token_store.go new file mode 100644 index 00000000..80883a64 --- /dev/null +++ b/internal/auth/token_store.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/larksuite/cli/internal/keychain" +) + +// StoredUAToken represents a stored user access token. +type StoredUAToken struct { + UserOpenId string `json:"userOpenId"` + AppId string `json:"appId"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` // Unix ms + RefreshExpiresAt int64 `json:"refreshExpiresAt"` // Unix ms + Scope string `json:"scope"` + GrantedAt int64 `json:"grantedAt"` // Unix ms +} + +const refreshAheadMs = 5 * 60 * 1000 // 5 minutes + +func accountKey(appId, userOpenId string) string { + return fmt.Sprintf("%s:%s", appId, userOpenId) +} + +// MaskToken masks a token for safe logging. +func MaskToken(token string) string { + if len(token) <= 8 { + return "****" + } + return "****" + token[len(token)-4:] +} + +// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair. +func GetStoredToken(appId, userOpenId string) *StoredUAToken { + jsonStr := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId)) + if jsonStr == "" { + return nil + } + var token StoredUAToken + if err := json.Unmarshal([]byte(jsonStr), &token); err != nil { + return nil + } + return &token +} + +// SetStoredToken persists a UAT. +func SetStoredToken(token *StoredUAToken) error { + key := accountKey(token.AppId, token.UserOpenId) + data, err := json.Marshal(token) + if err != nil { + return err + } + return keychain.Set(keychain.LarkCliService, key, string(data)) +} + +// RemoveStoredToken removes a stored UAT. +func RemoveStoredToken(appId, userOpenId string) error { + return keychain.Remove(keychain.LarkCliService, accountKey(appId, userOpenId)) +} + +// TokenStatus determines the freshness of a stored token. +func TokenStatus(token *StoredUAToken) string { + now := time.Now().UnixMilli() + if now < token.ExpiresAt-refreshAheadMs { + return "valid" + } + if now < token.RefreshExpiresAt { + return "needs_refresh" + } + return "expired" +} diff --git a/internal/auth/transport.go b/internal/auth/transport.go new file mode 100644 index 00000000..2a167049 --- /dev/null +++ b/internal/auth/transport.go @@ -0,0 +1,199 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses +// and checks for security policy errors. +type SecurityPolicyTransport struct { + Base http.RoundTripper +} + +func (t *SecurityPolicyTransport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + +// RoundTrip implements http.RoundTripper. +func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.base().RoundTrip(req) + if err != nil { + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + return nil, err + } + if resp == nil || resp.Body == nil { + return resp, nil + } + + // Only process JSON responses to avoid memory spikes on large files + contentType := strings.ToLower(resp.Header.Get("Content-Type")) + if !strings.Contains(contentType, "application/json") { + return resp, nil + } + + // Read up to 64KB of the body to check for security policy errors + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + resp.Body.Close() + return nil, fmt.Errorf("failed to read response body in security transport: %w", err) + } + + // Restore the body so it can be read by the caller, preserving streaming capability + resp.Body = struct { + io.Reader + io.Closer + }{ + io.MultiReader(bytes.NewReader(bodyBytes), resp.Body), + resp.Body, + } + + // Try to parse it as JSON + var result map[string]interface{} + if err := json.Unmarshal(bodyBytes, &result); err != nil { + return resp, nil + } + + // 1. Try to handle as MCP (JSON-RPC) format first + if err := t.tryHandleMCPResponse(result); err != nil { + resp.Body.Close() + return nil, err + } + + // 2. Try to handle as OpenAPI error format + if err := t.tryHandleOAPIResponse(result); err != nil { + resp.Body.Close() + return nil, err + } + + return resp, nil +} + +func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error { + // MCP (JSON-RPC) response format: + // { + // "error": { + // "code": 21000, + // "message": "...", + // "data": { "challenge_url": "...", "cli_hint": "..." } + // } + // } + errMap, ok := result["error"].(map[string]interface{}) + if !ok { + return nil + } + + code := getInt(errMap, "code", 0) + if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy { + return nil + } + + dataMap, ok := errMap["data"].(map[string]interface{}) + if !ok { + return nil + } + + // Clean up backticks and spaces from challenge_url + challengeUrl := strings.Trim(getStr(dataMap, "challenge_url"), " `") + cliHint := getStr(dataMap, "cli_hint") + msg := getStr(errMap, "message") + + if challengeUrl != "" || cliHint != "" { + // Security validation for challengeUrl + if challengeUrl != "" && !isValidChallengeURL(challengeUrl) { + challengeUrl = "" + } + + if challengeUrl != "" || cliHint != "" { + return &SecurityPolicyError{ + Code: code, + Message: msg, + ChallengeURL: challengeUrl, + CLIHint: cliHint, + } + } + } + + return nil +} + +func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interface{}) error { + // 1. Extract code + code := getInt(result, "code", 0) + + // If code is 0, check if it's already in our error format {"error": {"code": 21000, ...}, "ok": false} + if code == 0 { + if errMap, ok := result["error"].(map[string]interface{}); ok { + code = getInt(errMap, "code", 0) + } + } + + // 2. Check if it's a security policy error + if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy { + return nil + } + + // 3. Extract details + var challengeUrl, cliHint, msg string + if dataMap, ok := result["data"].(map[string]interface{}); ok { + // Standard OAPI format + challengeUrl = getStr(dataMap, "challenge_url") + cliHint = getStr(dataMap, "cli_hint") + msg = getStr(result, "msg") + } else if errMap, ok := result["error"].(map[string]interface{}); ok { + // Already formatted error format (e.g. from internal API or CLI output) + challengeUrl = getStr(errMap, "challenge_url") + cliHint = getStr(errMap, "hint") + msg = getStr(errMap, "message") + } + + // 4. Print and exit if we have enough info + if msg != "" || challengeUrl != "" || cliHint != "" { + // Security validation for challengeUrl + if challengeUrl != "" && !isValidChallengeURL(challengeUrl) { + challengeUrl = "" + } + + if msg != "" || challengeUrl != "" || cliHint != "" { + return &SecurityPolicyError{ + Code: code, + Message: msg, + ChallengeURL: challengeUrl, + CLIHint: cliHint, + } + } + } + + return nil +} + +func isValidChallengeURL(rawURL string) bool { + if rawURL == "" { + return false + } + + u, err := url.Parse(rawURL) + if err != nil { + return false + } + + // 1. Must be https + if u.Scheme != "https" { + return false + } + + return true +} diff --git a/internal/auth/uat_client.go b/internal/auth/uat_client.go new file mode 100644 index 00000000..133c9c7e --- /dev/null +++ b/internal/auth/uat_client.go @@ -0,0 +1,305 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/gofrs/flock" + "github.com/larksuite/cli/internal/core" +) + +var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func sanitizeID(id string) string { + return safeIDChars.ReplaceAllString(id, "_") +} + +// UATCallOptions contains options for UAT API calls. +type UATCallOptions struct { + UserOpenId string + AppId string + AppSecret string + Domain core.LarkBrand + ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut) +} + +// UATStatus represents the status of a user access token. +type UATStatus struct { + Authorized bool `json:"authorized"` + UserOpenId string `json:"userOpenId"` + Scope string `json:"scope,omitempty"` + ExpiresAt int64 `json:"expiresAt,omitempty"` + RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"` + GrantedAt int64 `json:"grantedAt,omitempty"` + TokenStatus string `json:"tokenStatus,omitempty"` +} + +// NewUATCallOptions creates UATCallOptions from a CLI config. +func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions { + if errOut == nil { + errOut = os.Stderr + } + return UATCallOptions{ + UserOpenId: cfg.UserOpenId, + AppId: cfg.AppID, + AppSecret: cfg.AppSecret, + Domain: cfg.Brand, + ErrOut: errOut, + } +} + +var refreshLocks sync.Map + +// GetValidAccessToken obtains a valid access token for the given user. +func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) { + stored := GetStoredToken(opts.AppId, opts.UserOpenId) + if stored == nil { + return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId} + } + + status := TokenStatus(stored) + + if status == "valid" { + return stored.AccessToken, nil + } + + if status == "needs_refresh" { + refreshed, err := refreshWithLock(httpClient, opts, stored) + if err != nil { + return "", err + } + if refreshed == nil { + return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId} + } + return refreshed.AccessToken, nil + } + + // expired + if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil { + if opts.ErrOut != nil { + fmt.Fprintf(opts.ErrOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) + } + } + return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId} +} + +func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) { + key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId) + + // 1. Process-level lock (prevents multiple goroutines in the same process) + done := make(chan struct{}) + if existing, loaded := refreshLocks.LoadOrStore(key, done); loaded { + // Another goroutine is already refreshing; wait for it + if ch, ok := existing.(chan struct{}); ok { + <-ch + } else { + // fallback in case of unexpected type + refreshLocks.Delete(key) + } + return GetStoredToken(opts.AppId, opts.UserOpenId), nil + } + + // We own the process lock; done is the channel stored in the map + defer func() { + close(done) + refreshLocks.Delete(key) + }() + + // 2. Cross-process lock using flock + // We use the same underlying storage directory resolution as keychain_other.go + // to ensure locks are isolated properly alongside other sensitive data. + configDir := core.GetConfigDir() + + lockDir := filepath.Join(configDir, "locks") + if err := os.MkdirAll(lockDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create lock directory: %w", err) + } + + safeAppId := sanitizeID(opts.AppId) + safeUserOpenId := sanitizeID(opts.UserOpenId) + lockFile := filepath.Join(lockDir, fmt.Sprintf("refresh_%s_%s.lock", safeAppId, safeUserOpenId)) + fileLock := flock.New(lockFile) + + // Try to acquire the lock, wait if necessary + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + locked, err := fileLock.TryLockContext(ctx, 500*time.Millisecond) + if err != nil { + return nil, fmt.Errorf("failed to acquire cross-process lock: %w", err) + } + if !locked { + return nil, fmt.Errorf("timeout waiting for cross-process lock") + } + defer fileLock.Unlock() + + // 3. Double-checked locking: Check if another process has already refreshed the token + freshStored := GetStoredToken(opts.AppId, opts.UserOpenId) + if freshStored != nil { + status := TokenStatus(freshStored) + if status == "valid" { + // Another process refreshed it, we can just use the new token + if opts.ErrOut != nil { + fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n") + } + return freshStored, nil + } + } + + // 4. Actually perform the refresh + return doRefreshToken(httpClient, opts, stored) +} + +func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) { + errOut := opts.ErrOut + if errOut == nil { + errOut = os.Stderr + } + + now := time.Now().UnixMilli() + if now >= stored.RefreshExpiresAt { + fmt.Fprintf(errOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId) + if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove expired token: %v\n", err) + } + return nil, nil + } + + endpoints := ResolveOAuthEndpoints(opts.Domain) + + callEndpoint := func() (map[string]interface{}, error) { + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", stored.RefreshToken) + form.Set("client_id", opts.AppId) + form.Set("client_secret", opts.AppSecret) + + req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("token refresh read error: %v", err) + } + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("token refresh parse error: %v", err) + } + return data, nil + } + + data, err := callEndpoint() + if err != nil { + return nil, err + } + + code := getInt(data, "code", -1) + if code == LarkErrBlockByPolicy || code == LarkErrBlockByPolicyTryAuth { + challengeUrl := getStr(data, "challenge_url") + cliHint := getStr(data, "cli_hint") + msg := getStr(data, "error_description") + + return nil, &SecurityPolicyError{ + Code: code, + Message: msg, + ChallengeURL: challengeUrl, + CLIHint: cliHint, + } + } + + errStr := getStr(data, "error") + + if (code != -1 && code != 0) || errStr != "" { + // Retryable server error: retry once, then clear token on second failure. + if RefreshTokenRetryable[code] { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId) + data, err = callEndpoint() + if err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh retry network error for %s, clearing token\n", opts.UserOpenId) + if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) + } + return nil, nil + } + code = getInt(data, "code", -1) + errStr = getStr(data, "error") + if (code != -1 && code != 0) || errStr != "" { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed after retry (code=%d) for %s, clearing token\n", code, opts.UserOpenId) + if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) + } + return nil, nil + } + // Retry succeeded, fall through to parse token below. + } else { + // All other errors: clear token, require re-authorization. + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed (code=%d), clearing token for %s\n", code, opts.UserOpenId) + if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) + } + return nil, nil + } + } + + accessToken := getStr(data, "access_token") + if accessToken == "" { + return nil, fmt.Errorf("Token refresh returned no access_token") + } + + refreshToken := getStr(data, "refresh_token") + if refreshToken == "" { + refreshToken = stored.RefreshToken + } + + expiresIn := getInt(data, "expires_in", 7200) + refreshExpiresIn := getInt(data, "refresh_token_expires_in", 0) + refreshExpiresAt := stored.RefreshExpiresAt + if refreshExpiresIn > 0 { + refreshExpiresAt = now + int64(refreshExpiresIn)*1000 + } + + scope := getStr(data, "scope") + if scope == "" { + scope = stored.Scope + } + + updated := &StoredUAToken{ + UserOpenId: stored.UserOpenId, + AppId: opts.AppId, + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: now + int64(expiresIn)*1000, + RefreshExpiresAt: refreshExpiresAt, + Scope: scope, + GrantedAt: stored.GrantedAt, + } + + if err := SetStoredToken(updated); err != nil { + return nil, err + } + return updated, nil +} diff --git a/internal/auth/uat_client_options_test.go b/internal/auth/uat_client_options_test.go new file mode 100644 index 00000000..2c7d26c1 --- /dev/null +++ b/internal/auth/uat_client_options_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "bytes" + "testing" + + "github.com/larksuite/cli/internal/core" +) + +func TestNewUATCallOptions(t *testing.T) { + cfg := &core.CliConfig{ + AppID: "app123", + AppSecret: "secret", + Brand: core.BrandLark, + UserOpenId: "ou_test", + } + errOut := &bytes.Buffer{} + + opts := NewUATCallOptions(cfg, errOut) + + if opts.AppId != "app123" { + t.Errorf("AppId = %q, want app123", opts.AppId) + } + if opts.AppSecret != "secret" { + t.Errorf("AppSecret = %q, want secret", opts.AppSecret) + } + if opts.Domain != core.BrandLark { + t.Errorf("Domain = %q, want lark", opts.Domain) + } + if opts.UserOpenId != "ou_test" { + t.Errorf("UserOpenId = %q, want ou_test", opts.UserOpenId) + } + if opts.ErrOut != errOut { + t.Error("ErrOut not set correctly") + } +} diff --git a/internal/auth/verify.go b/internal/auth/verify.go new file mode 100644 index 00000000..c10c9f81 --- /dev/null +++ b/internal/auth/verify.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// VerifyUserToken calls /authen/v1/user_info to confirm the token is accepted server-side. +// Returns nil on success or an error describing why the server rejected the token. +func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) error { + apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/authen/v1/user_info", + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}, + }, larkcore.WithUserAccessToken(accessToken)) + if err != nil { + return err + } + + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + if resp.Code != 0 { + return fmt.Errorf("[%d] %s", resp.Code, resp.Msg) + } + return nil +} diff --git a/internal/auth/verify_test.go b/internal/auth/verify_test.go new file mode 100644 index 00000000..507d221f --- /dev/null +++ b/internal/auth/verify_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "strings" + "testing" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestVerifyUserToken_TransportError(t *testing.T) { + reg := &httpmock.Registry{} + // Register no stubs — any request will fail with "no stub" error + sdk := lark.NewClient("test-app", "test-secret", + lark.WithLogLevel(larkcore.LogLevelError), + lark.WithHttpClient(httpmock.NewClient(reg)), + ) + + err := VerifyUserToken(context.Background(), sdk, "test-token") + if err == nil { + t.Fatal("expected error from transport failure, got nil") + } +} + +func TestVerifyUserToken(t *testing.T) { + tests := []struct { + name string + body interface{} + wantErr bool + errSubstr string + }{ + { + name: "success", + body: map[string]interface{}{"code": 0, "msg": "ok"}, + wantErr: false, + }, + { + name: "token invalid", + body: map[string]interface{}{"code": 99991668, "msg": "invalid token"}, + wantErr: true, + errSubstr: "[99991668]", + }, + { + name: "non-JSON response", + body: "not json", + wantErr: true, + errSubstr: "invalid character", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + t.Cleanup(func() { reg.Verify(t) }) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/authen/v1/user_info", + Body: tt.body, + }) + + sdk := lark.NewClient("test-app", "test-secret", + lark.WithLogLevel(larkcore.LogLevelError), + lark.WithHttpClient(httpmock.NewClient(reg)), + ) + + err := VerifyUserToken(context.Background(), sdk, "test-token") + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.errSubstr) { + t.Errorf("error %q does not contain %q", err.Error(), tt.errSubstr) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} diff --git a/internal/build/build.go b/internal/build/build.go new file mode 100644 index 00000000..865a475c --- /dev/null +++ b/internal/build/build.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package build + +import "runtime/debug" + +// Version is dynamically set by -ldflags or falls back to module info. +var Version = "DEV" + +// Date is the build date in YYYY-MM-DD format, set by -ldflags. +var Date = "" + +func init() { + if Version == "DEV" { + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" { + Version = info.Main.Version + } + } + if Version == "" { + Version = "DEV" + } +} diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 00000000..030a0ded --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,270 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" +) + +// RawApiRequest describes a raw API request. +type RawApiRequest struct { + Method string + URL string + Params map[string]interface{} + Data interface{} + As core.Identity + ExtraOpts []larkcore.RequestOptionFunc // additional SDK request options (e.g. security headers) +} + +// APIClient wraps lark.Client for all Lark Open API calls. +type APIClient struct { + Config *core.CliConfig + SDK *lark.Client // All Lark API calls go through SDK + HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.) + ErrOut io.Writer // debug/progress output +} + +// buildApiReq converts a RawApiRequest into SDK types and collects +// request-specific options (ExtraOpts, URL-based headers). +// Auth is handled separately by DoSDKRequest. +func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []larkcore.RequestOptionFunc) { + queryParams := make(larkcore.QueryParams) + for k, v := range request.Params { + switch val := v.(type) { + case []string: + queryParams[k] = val + case []interface{}: + for _, item := range val { + queryParams.Add(k, fmt.Sprintf("%v", item)) + } + default: + queryParams.Set(k, fmt.Sprintf("%v", v)) + } + } + + apiReq := &larkcore.ApiReq{ + HttpMethod: strings.ToUpper(request.Method), + ApiPath: request.URL, + Body: request.Data, + QueryParams: queryParams, + } + + var opts []larkcore.RequestOptionFunc + opts = append(opts, request.ExtraOpts...) + return apiReq, opts +} + +// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request. +// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest) +// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls). +func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { + var opts []larkcore.RequestOptionFunc + + if as.IsBot() { + req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant} + } else { + req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser} + if c.Config.UserOpenId == "" { + return nil, fmt.Errorf("login required: lark-cli auth login (or use --as bot)") + } + token, err := auth.GetValidAccessToken(c.HTTP, auth.NewUATCallOptions(c.Config, c.ErrOut)) + if err != nil { + return nil, err + } + opts = append(opts, larkcore.WithUserAccessToken(token)) + } + + opts = append(opts, extraOpts...) + return c.SDK.Do(ctx, req, opts...) +} + +// DoAPI executes a raw Lark SDK request and returns the raw *larkcore.ApiResp. +// Unlike CallAPI which always JSON-decodes, DoAPI returns the raw response — suitable +// for file downloads (pass larkcore.WithFileDownload() via request.ExtraOpts) and +// any endpoint whose Content-Type may not be JSON. +func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore.ApiResp, error) { + apiReq, extraOpts := c.buildApiReq(request) + return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...) +} + +// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse. +// Use DoAPI directly when the response may not be JSON (e.g. file downloads). +func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) { + resp, err := c.DoAPI(ctx, request) + if err != nil { + return nil, err + } + return ParseJSONResponse(resp) +} + +// paginateLoop runs the core pagination loop. For each successful page (code == 0), +// it calls onResult if non-nil. It always accumulates and returns all raw page results. +func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{})) ([]interface{}, error) { + var allResults []interface{} + var pageToken string + page := 0 + pageDelay := opts.PageDelay + if pageDelay == 0 { + pageDelay = 200 + } + + for { + page++ + params := make(map[string]interface{}) + for k, v := range request.Params { + params[k] = v + } + if pageToken != "" { + params["page_token"] = pageToken + } + + fmt.Fprintf(c.ErrOut, "[page %d] fetching...\n", page) + result, err := c.CallAPI(ctx, RawApiRequest{ + Method: request.Method, + URL: request.URL, + Params: params, + Data: request.Data, + As: request.As, + ExtraOpts: request.ExtraOpts, + }) + if err != nil { + if page == 1 { + return nil, err + } + fmt.Fprintf(c.ErrOut, "[page %d] error, stopping pagination\n", page) + break + } + + if resultMap, ok := result.(map[string]interface{}); ok { + code, _ := util.ToFloat64(resultMap["code"]) + if code != 0 { + allResults = append(allResults, result) + if page == 1 { + return allResults, nil + } + fmt.Fprintf(c.ErrOut, "[page %d] API error (code=%.0f), stopping pagination\n", page, code) + break + } + } + + if onResult != nil { + onResult(result) + } + allResults = append(allResults, result) + + pageToken = "" + if resultMap, ok := result.(map[string]interface{}); ok { + if data, ok := resultMap["data"].(map[string]interface{}); ok { + hasMore, _ := data["has_more"].(bool) + if hasMore { + if pt, ok := data["page_token"].(string); ok && pt != "" { + pageToken = pt + } else if pt, ok := data["next_page_token"].(string); ok && pt != "" { + pageToken = pt + } + } + } + } + + if pageToken == "" { + break + } + + if opts.PageLimit > 0 && page >= opts.PageLimit { + fmt.Fprintf(c.ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", opts.PageLimit) + break + } + + if pageDelay > 0 { + time.Sleep(time.Duration(pageDelay) * time.Millisecond) + } + } + return allResults, nil +} + +// PaginateAll fetches all pages and returns a single merged result. +// Use this for formats that need the complete dataset (e.g. JSON). +func (c *APIClient) PaginateAll(ctx context.Context, request RawApiRequest, opts PaginationOptions) (interface{}, error) { + results, err := c.paginateLoop(ctx, request, opts, nil) + if err != nil { + return nil, err + } + if len(results) == 0 { + return map[string]interface{}{}, nil + } + if len(results) == 1 { + return results[0], nil + } + return mergePagedResults(c.ErrOut, results), nil +} + +// StreamPages fetches all pages and streams each page's list items via onItems. +// Returns the last page result (for error checking), whether any list items were found, +// and any network error. Use this for streaming formats (ndjson, table, csv). +func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}), opts PaginationOptions) (result interface{}, hasItems bool, err error) { + totalItems := 0 + results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) { + resultMap, ok := r.(map[string]interface{}) + if !ok { + return + } + data, ok := resultMap["data"].(map[string]interface{}) + if !ok { + return + } + arrayField := output.FindArrayField(data) + if arrayField == "" { + return + } + items, ok := data[arrayField].([]interface{}) + if !ok { + return + } + totalItems += len(items) + onItems(items) + hasItems = true + }) + if loopErr != nil { + return nil, false, loopErr + } + + if hasItems { + fmt.Fprintf(c.ErrOut, "[pagination] streamed %d pages, %d total items\n", len(results), totalItems) + } + + if len(results) > 0 { + return results[len(results)-1], hasItems, nil + } + return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil +} + +// CheckLarkResponse inspects a Lark API response for business-level errors (non-zero code). +// Uses type assertion instead of interface{} == nil to satisfy interface_nil_check lint. +// Returns nil if result is not a map, map is nil, or code is 0. +func CheckLarkResponse(result interface{}) error { + resultMap, ok := result.(map[string]interface{}) + if !ok || resultMap == nil { + return nil + } + code, _ := util.ToFloat64(resultMap["code"]) + if code == 0 { + return nil + } + larkCode := int(code) + msg, _ := resultMap["msg"].(string) + return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"]) +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 00000000..f0419f3a --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,356 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// roundTripFunc is an adapter to use a function as http.RoundTripper. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +// jsonResponse creates an HTTP response with JSON body. +func jsonResponse(body interface{}) *http.Response { + b, _ := json.Marshal(body) + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(b)), + } +} + +// newTestAPIClient creates an APIClient with a mock HTTP transport. +func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) { + t.Helper() + errBuf := &bytes.Buffer{} + httpClient := &http.Client{Transport: rt} + sdk := lark.NewClient("test-app", "test-secret", + lark.WithLogLevel(larkcore.LogLevelError), + lark.WithHttpClient(httpClient), + ) + return &APIClient{ + SDK: sdk, + ErrOut: errBuf, + }, errBuf +} + +func TestIsJSONContentType(t *testing.T) { + tests := []struct { + ct string + want bool + }{ + {"application/json", true}, + {"application/json; charset=utf-8", true}, + {"text/json", true}, + {"application/octet-stream", false}, + {"image/png", false}, + {"text/html", false}, + {"", false}, + } + for _, tt := range tests { + if got := IsJSONContentType(tt.ct); got != tt.want { + t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want) + } + } +} + +func TestMimeToExt(t *testing.T) { + tests := []struct { + ct string + want string + }{ + {"image/png", ".png"}, + {"image/jpeg", ".jpg"}, + {"application/pdf", ".pdf"}, + {"text/plain", ".txt"}, + {"application/octet-stream", ".bin"}, + {"", ".bin"}, + } + for _, tt := range tests { + if got := mimeToExt(tt.ct); got != tt.want { + t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want) + } + } +} + +func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) { + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "tenant_access_token"): + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-token", "expire": 7200, + }), nil + default: + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "user_id": "u123", + "name": "Test User", + }, + }), nil + } + }) + + ac, errBuf := newTestAPIClient(t, rt) + + result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{ + Method: "GET", + URL: "/open-apis/contact/v3/users/u123", + As: "bot", + }, func(items []interface{}) { + t.Error("onItems should not be called for non-batch API") + }, PaginationOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hasItems { + t.Error("expected hasItems=false for non-batch API") + } + if strings.Contains(errBuf.String(), "[pagination] streamed") { + t.Error("expected no pagination summary log for non-batch API") + } + if result == nil { + t.Fatal("expected non-nil result") + } + resultMap, ok := result.(map[string]interface{}) + if !ok { + t.Fatal("expected result to be a map") + } + data, _ := resultMap["data"].(map[string]interface{}) + if data["user_id"] != "u123" { + t.Errorf("expected user_id=u123, got %v", data["user_id"]) + } +} + +func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) { + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "tenant_access_token"): + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-token", "expire": 7200, + }), nil + default: + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, + "has_more": false, + }, + }), nil + } + }) + + ac, errBuf := newTestAPIClient(t, rt) + + var streamedItems []interface{} + result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{ + Method: "GET", + URL: "/open-apis/contact/v3/users", + As: "bot", + }, func(items []interface{}) { + streamedItems = append(streamedItems, items...) + }, PaginationOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !hasItems { + t.Error("expected hasItems=true for batch API") + } + if len(streamedItems) != 2 { + t.Errorf("expected 2 streamed items, got %d", len(streamedItems)) + } + if !strings.Contains(errBuf.String(), "[pagination] streamed") { + t.Error("expected pagination summary log for batch API") + } + if result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestPaginateAll_PageLimitStopsPagination(t *testing.T) { + apiCalls := 0 + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "tenant_access_token"): + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-token", "expire": 7200, + }), nil + default: + apiCalls++ + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": apiCalls}}, + "has_more": true, + "page_token": "next", + }, + }), nil + } + }) + + ac, errBuf := newTestAPIClient(t, rt) + + _, err := ac.PaginateAll(context.Background(), RawApiRequest{ + Method: "GET", + URL: "/open-apis/test", + As: "bot", + }, PaginationOptions{PageLimit: 2, PageDelay: 0}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if apiCalls != 2 { + t.Errorf("expected 2 API calls with PageLimit=2, got %d", apiCalls) + } + if !strings.Contains(errBuf.String(), "reached page limit (2), stopping. Use --page-all --page-limit 0 to fetch all pages.") { + t.Errorf("expected page limit log, got: %s", errBuf.String()) + } +} + +func TestBuildApiReq_QueryParams(t *testing.T) { + ac := &APIClient{} + + tests := []struct { + name string + params map[string]interface{} + want larkcore.QueryParams + }{ + { + name: "scalar values", + params: map[string]interface{}{"page_size": 20, "user_id_type": "open_id"}, + want: larkcore.QueryParams{ + "page_size": []string{"20"}, + "user_id_type": []string{"open_id"}, + }, + }, + { + name: "[]interface{} array", + params: map[string]interface{}{"department_ids": []interface{}{"d1", "d2", "d3"}}, + want: larkcore.QueryParams{ + "department_ids": []string{"d1", "d2", "d3"}, + }, + }, + { + name: "[]string array", + params: map[string]interface{}{"statuses": []string{"active", "inactive"}}, + want: larkcore.QueryParams{ + "statuses": []string{"active", "inactive"}, + }, + }, + { + name: "mixed scalar and array", + params: map[string]interface{}{ + "user_id_type": "open_id", + "ids": []interface{}{"id1", "id2"}, + }, + want: larkcore.QueryParams{ + "user_id_type": []string{"open_id"}, + "ids": []string{"id1", "id2"}, + }, + }, + { + name: "empty array", + params: map[string]interface{}{"tags": []interface{}{}}, + want: larkcore.QueryParams{}, + }, + { + name: "nil params", + params: nil, + want: larkcore.QueryParams{}, + }, + { + name: "bool value", + params: map[string]interface{}{"with_bot": true}, + want: larkcore.QueryParams{"with_bot": []string{"true"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiReq, _ := ac.buildApiReq(RawApiRequest{ + Method: "GET", + URL: "/open-apis/test", + Params: tt.params, + }) + got := apiReq.QueryParams + // Check all expected keys exist with correct values + for k, wantVals := range tt.want { + gotVals, ok := got[k] + if !ok { + t.Errorf("missing key %q", k) + continue + } + if len(gotVals) != len(wantVals) { + t.Errorf("key %q: got %d values %v, want %d values %v", k, len(gotVals), gotVals, len(wantVals), wantVals) + continue + } + for i := range wantVals { + if gotVals[i] != wantVals[i] { + t.Errorf("key %q[%d]: got %q, want %q", k, i, gotVals[i], wantVals[i]) + } + } + } + // Check no unexpected keys + for k := range got { + if _, ok := tt.want[k]; !ok { + t.Errorf("unexpected key %q with values %v", k, got[k]) + } + } + }) + } +} + +func TestPaginateAll_NoStreamSummaryLog(t *testing.T) { + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "tenant_access_token"): + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-token", "expire": 7200, + }), nil + default: + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "1"}}, + "has_more": false, + }, + }), nil + } + }) + + ac, errBuf := newTestAPIClient(t, rt) + + result, err := ac.PaginateAll(context.Background(), RawApiRequest{ + Method: "GET", + URL: "/open-apis/contact/v3/users", + As: "bot", + }, PaginationOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(errBuf.String(), "[pagination] streamed") { + t.Error("expected no streaming summary log from PaginateAll") + } + if result == nil { + t.Fatal("expected non-nil result") + } +} diff --git a/internal/client/pagination.go b/internal/client/pagination.go new file mode 100644 index 00000000..4c12979d --- /dev/null +++ b/internal/client/pagination.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" +) + +// PaginationOptions contains pagination control options. +type PaginationOptions struct { + PageLimit int // max pages to fetch; 0 = unlimited (default: 10) + PageDelay int // ms, default 200 +} + +func mergePagedResults(w io.Writer, results []interface{}) interface{} { + if len(results) == 0 { + return map[string]interface{}{} + } + + firstMap, ok := results[0].(map[string]interface{}) + if !ok { + return map[string]interface{}{"pages": results} + } + + data, ok := firstMap["data"].(map[string]interface{}) + if !ok { + return map[string]interface{}{"pages": results} + } + + arrayField := output.FindArrayField(data) + if arrayField == "" { + return map[string]interface{}{"pages": results} + } + + var merged []interface{} + for _, r := range results { + if rm, ok := r.(map[string]interface{}); ok { + if d, ok := rm["data"].(map[string]interface{}); ok { + if items, ok := d[arrayField].([]interface{}); ok { + merged = append(merged, items...) + } + } + } + } + + fmt.Fprintf(w, "[pagination] merged %d pages, %d total items\n", len(results), len(merged)) + + mergedData := make(map[string]interface{}) + for k, v := range data { + mergedData[k] = v + } + mergedData[arrayField] = merged + mergedData["has_more"] = false + delete(mergedData, "page_token") + delete(mergedData, "next_page_token") + + result := make(map[string]interface{}) + for k, v := range firstMap { + result[k] = v + } + result["data"] = mergedData + + return result +} diff --git a/internal/client/response.go b/internal/client/response.go new file mode 100644 index 00000000..0a76d57c --- /dev/null +++ b/internal/client/response.go @@ -0,0 +1,188 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" +) + +// ── Response routing ── + +// ResponseOptions configures how HandleResponse routes a raw API response. +type ResponseOptions struct { + OutputPath string // --output flag; "" = auto-detect + Format output.Format // output format for JSON responses + Out io.Writer // stdout + ErrOut io.Writer // stderr + // CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse. + CheckError func(interface{}) error +} + +// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output: +// 1. If Content-Type is JSON, check for business errors first (even with --output). +// 2. If --output is set and response is not a JSON error, save to file. +// 3. If Content-Type is non-JSON and no --output, auto-save binary to file. +func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { + ct := resp.Header.Get("Content-Type") + check := opts.CheckError + if check == nil { + check = CheckLarkResponse + } + + // Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly + // instead of falling through to the binary-save path. + if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" { + body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500) + return output.Errorf(httpExitCode(resp.StatusCode), "http_error", "HTTP %d: %s", resp.StatusCode, body) + } + + // JSON responses: always check for business errors before saving. + if IsJSONContentType(ct) || ct == "" { + result, err := ParseJSONResponse(resp) + if err != nil { + return output.ErrNetwork("API call failed: %v", err) + } + if apiErr := check(result); apiErr != nil { + return apiErr + } + if opts.OutputPath != "" { + return saveAndPrint(resp, opts.OutputPath, opts.Out) + } + output.FormatValue(opts.Out, result, opts.Format) + return nil + } + + // Non-JSON (binary) responses. + if opts.OutputPath != "" { + return saveAndPrint(resp, opts.OutputPath, opts.Out) + } + + // No --output: auto-save with derived filename. + meta, err := SaveResponse(resp, ResolveFilename(resp)) + if err != nil { + return output.Errorf(output.ExitInternal, "file_error", "%s", err) + } + fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct) + output.PrintJson(opts.Out, meta) + return nil +} + +func saveAndPrint(resp *larkcore.ApiResp, path string, w io.Writer) error { + meta, err := SaveResponse(resp, path) + if err != nil { + return output.Errorf(output.ExitInternal, "file_error", "%s", err) + } + output.PrintJson(w, meta) + return nil +} + +// ── JSON helpers ── + +// IsJSONContentType reports whether the Content-Type header indicates a JSON response. +func IsJSONContentType(ct string) bool { + return strings.Contains(ct, "application/json") || strings.Contains(ct, "text/json") +} + +// ParseJSONResponse decodes a raw SDK response body as JSON. +// CallAPI and HandleResponse both delegate to this function. +func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) { + var result interface{} + dec := json.NewDecoder(bytes.NewReader(resp.RawBody)) + dec.UseNumber() + if err := dec.Decode(&result); err != nil { + return nil, fmt.Errorf("response parse error: %v (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500)) + } + return result, nil +} + +// ── File saving ── + +// SaveResponse writes an API response body to the given outputPath and returns metadata. +func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) { + safePath, err := validate.SafeOutputPath(outputPath) + if err != nil { + return nil, fmt.Errorf("unsafe output path: %s", err) + } + + if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { + return nil, fmt.Errorf("create directory: %s", err) + } + + if err := validate.AtomicWrite(safePath, resp.RawBody, 0644); err != nil { + return nil, fmt.Errorf("cannot write file: %s", err) + } + + return map[string]interface{}{ + "saved_path": safePath, + "size_bytes": len(resp.RawBody), + "content_type": resp.Header.Get("Content-Type"), + }, nil +} + +// ResolveFilename picks a filename from the response headers. +// Priority: Content-Disposition filename > Content-Type extension > "download.bin". +func ResolveFilename(resp *larkcore.ApiResp) string { + if name := larkcore.FileNameByHeader(resp.Header); name != "" { + return name + } + return "download" + mimeToExt(resp.Header.Get("Content-Type")) +} + +// mimeToExt maps a Content-Type to a file extension (with leading dot). +func mimeToExt(ct string) string { + if ct == "" { + return ".bin" + } + mediaType, _, _ := mime.ParseMediaType(ct) + switch mediaType { + case "application/pdf": + return ".pdf" + case "image/png": + return ".png" + case "image/jpeg": + return ".jpg" + case "image/gif": + return ".gif" + case "text/plain": + return ".txt" + case "text/csv": + return ".csv" + case "text/html": + return ".html" + case "application/zip": + return ".zip" + case "application/xml", "text/xml": + return ".xml" + case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": + return ".xlsx" + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return ".docx" + case "application/vnd.openxmlformats-officedocument.presentationml.presentation": + return ".pptx" + default: + return ".bin" + } +} + +// httpExitCode maps HTTP status ranges to CLI exit codes: +// 5xx → ExitNetwork (server error), 4xx → ExitAPI (client error). +func httpExitCode(status int) int { + if status >= 500 { + return output.ExitNetwork + } + return output.ExitAPI +} diff --git a/internal/client/response_test.go b/internal/client/response_test.go new file mode 100644 index 00000000..8bfc6f16 --- /dev/null +++ b/internal/client/response_test.go @@ -0,0 +1,334 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "bytes" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" +) + +func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp { + return newApiRespWithStatus(200, body, headers) +} + +func newApiRespWithStatus(status int, body []byte, headers map[string]string) *larkcore.ApiResp { + h := http.Header{} + for k, v := range headers { + h.Set(k, v) + } + return &larkcore.ApiResp{ + StatusCode: status, + Header: h, + RawBody: body, + } +} + +func TestIsJSONContentType_Extended(t *testing.T) { + tests := []struct { + ct string + want bool + }{ + {"application/json", true}, + {"application/json; charset=utf-8", true}, + {"text/json", true}, + {"application/octet-stream", false}, + {"", false}, + } + for _, tt := range tests { + if got := IsJSONContentType(tt.ct); got != tt.want { + t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want) + } + } +} + +func TestParseJSONResponse(t *testing.T) { + body := []byte(`{"code":0,"msg":"ok","data":{"id":"123"}}`) + resp := newApiResp(body, map[string]string{"Content-Type": "application/json"}) + result, err := ParseJSONResponse(resp) + if err != nil { + t.Fatalf("ParseJSONResponse failed: %v", err) + } + m, ok := result.(map[string]interface{}) + if !ok { + t.Fatal("expected map result") + } + if m["msg"] != "ok" { + t.Errorf("expected msg=ok, got %v", m["msg"]) + } +} + +func TestParseJSONResponse_Invalid(t *testing.T) { + resp := newApiResp([]byte(`not json`), map[string]string{"Content-Type": "application/json"}) + _, err := ParseJSONResponse(resp) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestResolveFilename(t *testing.T) { + tests := []struct { + name string + headers map[string]string + want string + }{ + { + "from content-type pdf", + map[string]string{"Content-Type": "application/pdf"}, + "download.pdf", + }, + { + "from content-type png", + map[string]string{"Content-Type": "image/png"}, + "download.png", + }, + { + "unknown type", + map[string]string{"Content-Type": "application/octet-stream"}, + "download.bin", + }, + { + "empty content-type", + map[string]string{}, + "download.bin", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := newApiResp([]byte("data"), tt.headers) + got := ResolveFilename(resp) + if got != tt.want { + t.Errorf("ResolveFilename() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMimeToExt_Extended(t *testing.T) { + tests := []struct { + ct string + want string + }{ + {"application/pdf", ".pdf"}, + {"image/png", ".png"}, + {"image/jpeg", ".jpg"}, + {"image/gif", ".gif"}, + {"text/plain", ".txt"}, + {"text/csv", ".csv"}, + {"text/html", ".html"}, + {"application/zip", ".zip"}, + {"application/xml", ".xml"}, + {"text/xml", ".xml"}, + {"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"}, + {"application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx"}, + {"application/vnd.openxmlformats-officedocument.presentationml.presentation", ".pptx"}, + {"application/octet-stream", ".bin"}, + {"", ".bin"}, + } + for _, tt := range tests { + if got := mimeToExt(tt.ct); got != tt.want { + t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want) + } + } +} + +func TestSaveResponse(t *testing.T) { + dir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origWd) + + body := []byte("hello binary data") + resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"}) + + meta, err := SaveResponse(resp, "test_output.bin") + if err != nil { + t.Fatalf("SaveResponse failed: %v", err) + } + if meta["size_bytes"] != len(body) { + t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"]) + } + + savedPath, _ := meta["saved_path"].(string) + data, err := os.ReadFile(savedPath) + if err != nil { + t.Fatalf("read saved file: %v", err) + } + if !bytes.Equal(data, body) { + t.Errorf("saved content mismatch") + } +} + +func TestSaveResponse_CreatesDir(t *testing.T) { + dir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origWd) + + resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"}) + + meta, err := SaveResponse(resp, filepath.Join("sub", "deep", "out.bin")) + if err != nil { + t.Fatalf("SaveResponse with nested dir failed: %v", err) + } + savedPath, _ := meta["saved_path"].(string) + if _, err := os.Stat(savedPath); err != nil { + t.Errorf("expected file to exist at %s", savedPath) + } +} + +func TestHandleResponse_JSON(t *testing.T) { + body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`) + resp := newApiResp(body, map[string]string{"Content-Type": "application/json"}) + + var out bytes.Buffer + var errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{ + Out: &out, + ErrOut: &errOut, + }) + if err != nil { + t.Fatalf("HandleResponse failed: %v", err) + } + if !bytes.Contains(out.Bytes(), []byte(`"code"`)) { + t.Errorf("expected JSON output, got: %s", out.String()) + } +} + +func TestHandleResponse_JSONWithError(t *testing.T) { + body := []byte(`{"code":99991400,"msg":"invalid token"}`) + resp := newApiResp(body, map[string]string{"Content-Type": "application/json"}) + + var out bytes.Buffer + var errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{ + Out: &out, + ErrOut: &errOut, + }) + if err == nil { + t.Error("expected error for non-zero code") + } +} + +func TestHandleResponse_BinaryAutoSave(t *testing.T) { + dir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origWd) + + resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"}) + + var out bytes.Buffer + var errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{ + Out: &out, + ErrOut: &errOut, + }) + if err != nil { + t.Fatalf("HandleResponse binary failed: %v", err) + } + if !bytes.Contains(errOut.Bytes(), []byte("binary response detected")) { + t.Errorf("expected binary detection message, got: %s", errOut.String()) + } +} + +func TestHandleResponse_BinaryWithOutput(t *testing.T) { + dir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origWd) + + resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"}) + + var out bytes.Buffer + var errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{ + OutputPath: "out.png", + Out: &out, + ErrOut: &errOut, + }) + if err != nil { + t.Fatalf("HandleResponse with output path failed: %v", err) + } + data, _ := os.ReadFile("out.png") + if string(data) != "PNG DATA" { + t.Errorf("expected saved PNG DATA, got: %s", data) + } +} + +func TestHandleResponse_NonJSONError_404(t *testing.T) { + resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + if err == nil { + t.Fatal("expected error for 404 text/plain") + } + got := err.Error() + if !strings.Contains(got, "HTTP 404") || !strings.Contains(got, "404 page not found") { + t.Errorf("expected 'HTTP 404: 404 page not found', got: %s", got) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI { + t.Errorf("expected ExitAPI (%d) for 4xx, got code: %d", output.ExitAPI, exitErr.Code) + } +} + +func TestHandleResponse_NonJSONError_502(t *testing.T) { + resp := newApiRespWithStatus(502, []byte("Bad Gateway"), map[string]string{"Content-Type": "text/html"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + if err == nil { + t.Fatal("expected error for 502 text/html") + } + got := err.Error() + if !strings.Contains(got, "HTTP 502") || !strings.Contains(got, "Bad Gateway") { + t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Code != output.ExitNetwork { + t.Errorf("expected ExitNetwork (%d) for 5xx, got code: %d", output.ExitNetwork, exitErr.Code) + } +} + +func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) { + dir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origWd) + + resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + if err != nil { + t.Fatalf("expected no error for 200 text/plain, got: %v", err) + } + if !strings.Contains(errOut.String(), "binary response detected") { + t.Errorf("expected binary detection message, got: %s", errOut.String()) + } +} + +func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) { + body := []byte(`{"code":99991400,"msg":"invalid token"}`) + resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + if err == nil { + t.Fatal("expected error for 403 JSON with non-zero code") + } + if !strings.Contains(err.Error(), "99991400") { + t.Errorf("expected lark error code in message, got: %s", err.Error()) + } +} diff --git a/internal/cmdutil/annotations.go b/internal/cmdutil/annotations.go new file mode 100644 index 00000000..1aacec7e --- /dev/null +++ b/internal/cmdutil/annotations.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import "github.com/spf13/cobra" + +const skipAuthCheckKey = "skipAuthCheck" + +// DisableAuthCheck marks a command (and all its children) as not requiring auth. +func DisableAuthCheck(cmd *cobra.Command) { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[skipAuthCheckKey] = "true" +} + +// IsAuthCheckDisabled returns true if the command or any ancestor has auth check disabled. +func IsAuthCheckDisabled(cmd *cobra.Command) bool { + for c := cmd; c != nil; c = c.Parent() { + if c.Annotations != nil && c.Annotations[skipAuthCheckKey] == "true" { + return true + } + } + return false +} diff --git a/internal/cmdutil/annotations_test.go b/internal/cmdutil/annotations_test.go new file mode 100644 index 00000000..6ee5bab1 --- /dev/null +++ b/internal/cmdutil/annotations_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestDisableAuthCheck(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + if IsAuthCheckDisabled(cmd) { + t.Error("expected auth check to be enabled by default") + } + + DisableAuthCheck(cmd) + if !IsAuthCheckDisabled(cmd) { + t.Error("expected auth check to be disabled after DisableAuthCheck") + } +} + +func TestIsAuthCheckDisabled_Inheritance(t *testing.T) { + parent := &cobra.Command{Use: "parent"} + child := &cobra.Command{Use: "child"} + parent.AddCommand(child) + + if IsAuthCheckDisabled(child) { + t.Error("expected child auth check enabled before parent annotation") + } + + DisableAuthCheck(parent) + if !IsAuthCheckDisabled(child) { + t.Error("expected child to inherit disabled auth check from parent") + } +} + +func TestIsAuthCheckDisabled_NoInheritanceUpward(t *testing.T) { + parent := &cobra.Command{Use: "parent"} + child := &cobra.Command{Use: "child"} + parent.AddCommand(child) + + DisableAuthCheck(child) + if IsAuthCheckDisabled(parent) { + t.Error("parent should not inherit disabled auth check from child") + } + if !IsAuthCheckDisabled(child) { + t.Error("child should have disabled auth check") + } +} diff --git a/internal/cmdutil/dryrun.go b/internal/cmdutil/dryrun.go new file mode 100644 index 00000000..57302a60 --- /dev/null +++ b/internal/cmdutil/dryrun.go @@ -0,0 +1,252 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "sort" + "strings" + + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" +) + +// DryRunAPICall describes a single API call in dry-run output. +type DryRunAPICall struct { + Desc string `json:"desc,omitempty"` + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params,omitempty"` + Body interface{} `json:"body,omitempty"` +} + +// DryRunAPI is the builder and result type for dry-run output. +// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them. +type DryRunAPI struct { + desc string + calls []DryRunAPICall + extra map[string]interface{} +} + +func NewDryRunAPI() *DryRunAPI { + return &DryRunAPI{extra: map[string]interface{}{}} +} + +// --- HTTP method builders (add a call, return self for chaining) --- + +func (d *DryRunAPI) GET(url string) *DryRunAPI { + d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url}) + return d +} + +func (d *DryRunAPI) POST(url string) *DryRunAPI { + d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url}) + return d +} + +func (d *DryRunAPI) PUT(url string) *DryRunAPI { + d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url}) + return d +} + +func (d *DryRunAPI) DELETE(url string) *DryRunAPI { + d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url}) + return d +} + +func (d *DryRunAPI) PATCH(url string) *DryRunAPI { + d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url}) + return d +} + +// Body sets the request body on the last added call. +func (d *DryRunAPI) Body(body interface{}) *DryRunAPI { + if n := len(d.calls); n > 0 { + d.calls[n-1].Body = body + } + return d +} + +// Params sets query parameters on the last added call. +func (d *DryRunAPI) Params(params map[string]interface{}) *DryRunAPI { + if n := len(d.calls); n > 0 { + d.calls[n-1].Params = params + } + return d +} + +// Desc sets a description on the last added call. +// If no calls exist yet, sets the top-level description. +func (d *DryRunAPI) Desc(desc string) *DryRunAPI { + if n := len(d.calls); n > 0 { + d.calls[n-1].Desc = desc + } else { + d.desc = desc + } + return d +} + +// Set adds an extra context field. Values are also used to resolve :key placeholders in URLs. +func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI { + d.extra[key] = value + return d +} + +// resolveURL replaces :key placeholders in url with path-escaped values from extra. +func (d *DryRunAPI) resolveURL(rawURL string) string { + for k, v := range d.extra { + rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v))) + } + return rawURL +} + +// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}. +func (d *DryRunAPI) MarshalJSON() ([]byte, error) { + resolved := make([]DryRunAPICall, len(d.calls)) + for i, c := range d.calls { + resolved[i] = DryRunAPICall{ + Desc: c.Desc, + Method: c.Method, + URL: d.resolveURL(c.URL), + Params: c.Params, + Body: c.Body, + } + } + m := make(map[string]interface{}, len(d.extra)+2) + if d.desc != "" { + m["description"] = d.desc + } + m["api"] = resolved + for k, v := range d.extra { + m[k] = v + } + return json.Marshal(m) +} + +// Format renders the dry-run output as plain text for AI/human consumption. +func (d *DryRunAPI) Format() string { + var b strings.Builder + + if d.desc != "" { + b.WriteString("# ") + b.WriteString(d.desc) + b.WriteByte('\n') + } + + for i, c := range d.calls { + if i > 0 || d.desc != "" { + b.WriteByte('\n') + } + if c.Desc != "" { + b.WriteString("# ") + b.WriteString(c.Desc) + b.WriteByte('\n') + } + + u := d.resolveURL(c.URL) + if len(c.Params) > 0 { + u += "?" + encodeParams(c.Params) + } + + method := c.Method + if method == "" { + method = "GET" + } + b.WriteString(method) + b.WriteByte(' ') + b.WriteString(u) + b.WriteByte('\n') + + if !util.IsNil(c.Body) { + j, _ := json.Marshal(c.Body) + b.WriteString(" ") + b.Write(j) + b.WriteByte('\n') + } + } + + if len(d.calls) == 0 && len(d.extra) > 0 { + if d.desc != "" { + b.WriteByte('\n') + } + keys := make([]string, 0, len(d.extra)) + for k := range d.extra { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + sv := dryRunFormatValue(d.extra[k]) + if sv == "" { + continue + } + b.WriteString(k) + b.WriteString(": ") + b.WriteString(sv) + b.WriteByte('\n') + } + } + + return b.String() +} + +func dryRunFormatValue(v interface{}) string { + switch val := v.(type) { + case string: + return val + case nil: + return "" + default: + j, _ := json.Marshal(val) + return string(j) + } +} + +func encodeParams(params map[string]interface{}) string { + vals := url.Values{} + for k, v := range params { + vals.Set(k, fmt.Sprintf("%v", v)) + } + return vals.Encode() +} + +// PrintDryRun outputs a standardised dry-run summary using DryRunAPI. +// When format is "pretty", outputs human-readable text; otherwise JSON. +func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error { + dr := NewDryRunAPI() + switch request.Method { + case "POST": + dr.POST(request.URL) + case "PUT": + dr.PUT(request.URL) + case "PATCH": + dr.PATCH(request.URL) + case "DELETE": + dr.DELETE(request.URL) + default: + dr.GET(request.URL) + } + if len(request.Params) > 0 { + dr.Params(request.Params) + } + if !util.IsNil(request.Data) { + dr.Body(request.Data) + } + dr.Set("as", string(request.As)) + dr.Set("appId", config.AppID) + if config.UserOpenId != "" { + dr.Set("userOpenId", config.UserOpenId) + } + fmt.Fprintln(w, "=== Dry Run ===") + if format == "pretty" { + fmt.Fprint(w, dr.Format()) + } else { + output.PrintJson(w, dr) + } + return nil +} diff --git a/internal/cmdutil/dryrun_test.go b/internal/cmdutil/dryrun_test.go new file mode 100644 index 00000000..35470d57 --- /dev/null +++ b/internal/cmdutil/dryrun_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/core" +) + +func TestDryRunAPI_SingleGET(t *testing.T) { + dr := NewDryRunAPI(). + Desc("list calendars"). + GET("/open-apis/calendar/v4/calendars") + + text := dr.Format() + if !strings.Contains(text, "# list calendars") { + t.Errorf("expected description in text output, got: %s", text) + } + if !strings.Contains(text, "GET /open-apis/calendar/v4/calendars") { + t.Errorf("expected GET line in text output, got: %s", text) + } +} + +func TestDryRunAPI_WithParams(t *testing.T) { + dr := NewDryRunAPI(). + GET("/open-apis/test"). + Params(map[string]interface{}{"page_size": 20}) + + text := dr.Format() + if !strings.Contains(text, "page_size=20") { + t.Errorf("expected query params in text output, got: %s", text) + } +} + +func TestDryRunAPI_WithBody(t *testing.T) { + dr := NewDryRunAPI(). + POST("/open-apis/test"). + Body(map[string]interface{}{"title": "hello"}) + + text := dr.Format() + if !strings.Contains(text, "POST /open-apis/test") { + t.Errorf("expected POST line, got: %s", text) + } + if !strings.Contains(text, `"title"`) { + t.Errorf("expected body in output, got: %s", text) + } +} + +func TestDryRunAPI_ResolveURL(t *testing.T) { + dr := NewDryRunAPI(). + GET("/open-apis/calendar/v4/calendars/:calendar_id/events"). + Set("calendar_id", "cal_abc123") + + text := dr.Format() + if !strings.Contains(text, "cal_abc123") { + t.Errorf("expected resolved calendar_id in URL, got: %s", text) + } + if strings.Contains(text, ":calendar_id") { + t.Errorf("expected placeholder to be resolved, got: %s", text) + } +} + +func TestDryRunAPI_MarshalJSON(t *testing.T) { + dr := NewDryRunAPI(). + Desc("test api"). + GET("/open-apis/test"). + Set("as", "user") + + data, err := json.Marshal(dr) + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if m["description"] != "test api" { + t.Errorf("expected description, got: %v", m["description"]) + } + if m["as"] != "user" { + t.Errorf("expected as=user, got: %v", m["as"]) + } + api, ok := m["api"].([]interface{}) + if !ok || len(api) != 1 { + t.Errorf("expected 1 api call, got: %v", m["api"]) + } +} + +func TestDryRunAPI_MultipleCalls(t *testing.T) { + dr := NewDryRunAPI(). + GET("/open-apis/first").Desc("step 1"). + POST("/open-apis/second").Desc("step 2") + + text := dr.Format() + if !strings.Contains(text, "# step 1") || !strings.Contains(text, "# step 2") { + t.Errorf("expected both step descriptions, got: %s", text) + } + if !strings.Contains(text, "GET /open-apis/first") || !strings.Contains(text, "POST /open-apis/second") { + t.Errorf("expected both calls, got: %s", text) + } +} + +func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) { + dr := NewDryRunAPI(). + Desc("info only"). + Set("calendar_id", "cal_123"). + Set("summary", "My Calendar") + + text := dr.Format() + if !strings.Contains(text, "calendar_id: cal_123") { + t.Errorf("expected extra field, got: %s", text) + } + if !strings.Contains(text, "summary: My Calendar") { + t.Errorf("expected extra field, got: %s", text) + } +} + +func TestPrintDryRun_JSON(t *testing.T) { + var buf bytes.Buffer + err := PrintDryRun(&buf, client.RawApiRequest{ + Method: "GET", + URL: "/open-apis/test", + As: "user", + }, &core.CliConfig{AppID: "app123"}, "json") + if err != nil { + t.Fatalf("PrintDryRun failed: %v", err) + } + out := buf.String() + if !strings.Contains(out, "=== Dry Run ===") { + t.Errorf("expected header, got: %s", out) + } + if !strings.Contains(out, "app123") { + t.Errorf("expected appId in output, got: %s", out) + } +} + +func TestPrintDryRun_Pretty(t *testing.T) { + var buf bytes.Buffer + err := PrintDryRun(&buf, client.RawApiRequest{ + Method: "POST", + URL: "/open-apis/test", + Data: map[string]interface{}{"key": "val"}, + As: "bot", + }, &core.CliConfig{AppID: "app456"}, "pretty") + if err != nil { + t.Fatalf("PrintDryRun failed: %v", err) + } + out := buf.String() + if !strings.Contains(out, "POST /open-apis/test") { + t.Errorf("expected POST line in pretty output, got: %s", out) + } +} + +func TestDryRunFormatValue(t *testing.T) { + tests := []struct { + name string + v interface{} + want string + }{ + {"string", "hello", "hello"}, + {"nil", nil, ""}, + {"number", 42, "42"}, + {"bool", true, "true"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := dryRunFormatValue(tt.v); got != tt.want { + t.Errorf("dryRunFormatValue(%v) = %q, want %q", tt.v, got, tt.want) + } + }) + } +} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go new file mode 100644 index 00000000..a53d0868 --- /dev/null +++ b/internal/cmdutil/factory.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + + lark "github.com/larksuite/oapi-sdk-go/v3" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/output" +) + +// ResolveConfig returns Config() for bot identity, or AuthConfig() for user identity. +func (f *Factory) ResolveConfig(as core.Identity) (*core.CliConfig, error) { + if as.IsBot() { + return f.Config() + } + return f.AuthConfig() +} + +// Factory holds shared dependencies injected into every command. +// All function fields are lazily initialized and cached after first call. +// In tests, replace any field to stub out external dependencies. +type Factory struct { + Config func() (*core.CliConfig, error) // lazily loads app config (credentials, brand, defaultAs) + AuthConfig func() (*core.CliConfig, error) // like Config but also requires a logged-in user + HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers) + LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls + IOStreams *IOStreams // stdin/stdout/stderr streams + + Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests) + IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected + ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call +} + +// ResolveAs returns the effective identity type. +// If the user explicitly passed --as, use that value; otherwise use the configured default. +// When the value is "auto" (or unset), auto-detect based on login state. +func (f *Factory) ResolveAs(cmd *cobra.Command, flagAs core.Identity) core.Identity { + f.IdentityAutoDetected = false + if cmd != nil && cmd.Flags().Changed("as") { + if flagAs != "auto" { + f.ResolvedIdentity = flagAs + return flagAs + } + // --as auto: fall through to auto-detect + } else if defaultAs := f.resolveDefaultAs(); defaultAs != "" && defaultAs != "auto" { + f.ResolvedIdentity = core.Identity(defaultAs) + return f.ResolvedIdentity + } + // Auto-detect based on login state + f.IdentityAutoDetected = true + result := f.autoDetectIdentity() + f.ResolvedIdentity = result + return result +} + +// resolveDefaultAs returns the configured default identity: env var > config file. +func (f *Factory) resolveDefaultAs() string { + if v := os.Getenv("LARKSUITE_CLI_DEFAULT_AS"); v != "" { + return v + } + if cfg, err := f.Config(); err == nil { + return cfg.DefaultAs + } + return "" +} + +// autoDetectIdentity checks the login state and returns user if logged in, bot otherwise. +func (f *Factory) autoDetectIdentity() core.Identity { + cfg, err := f.Config() + if err != nil || cfg.UserOpenId == "" { + return core.AsBot + } + stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId) + if stored == nil { + return core.AsBot + } + if auth.TokenStatus(stored) == "expired" { + return core.AsBot + } + return core.AsUser +} + +// CheckIdentity verifies the resolved identity is in the supported list. +// On success, sets f.ResolvedIdentity. On failure, returns an error +// tailored to whether the identity was explicit (--as) or auto-detected. +func (f *Factory) CheckIdentity(as core.Identity, supported []string) error { + for _, t := range supported { + if string(as) == t { + f.ResolvedIdentity = as + return nil + } + } + list := strings.Join(supported, ", ") + if f.IdentityAutoDetected { + return output.ErrValidation( + "resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s", + as, list, supported[0]) + } + return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list) +} + +// NewAPIClient creates an APIClient using the Factory's base Config (app credentials only). +// For user-mode calls where the correct user profile matters, use NewAPIClientWithConfig instead. +func (f *Factory) NewAPIClient() (*client.APIClient, error) { + cfg, err := f.Config() + if err != nil { + return nil, err + } + return f.NewAPIClientWithConfig(cfg) +} + +// NewAPIClientWithConfig creates an APIClient with an explicit config. +// Use this when the caller has already resolved the correct user profile +// (e.g. via AuthConfig for user-mode commands). +func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) { + sdk, err := f.LarkClient() + if err != nil { + return nil, err + } + httpClient, err := f.HttpClient() + if err != nil { + return nil, err + } + errOut := io.Discard + if f.IOStreams != nil { + errOut = f.IOStreams.ErrOut + } + return &client.APIClient{Config: cfg, SDK: sdk, HTTP: httpClient, ErrOut: errOut}, nil +} diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go new file mode 100644 index 00000000..adfbb582 --- /dev/null +++ b/internal/cmdutil/factory_default.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "fmt" + "net/http" + "os" + "sync" + "time" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "golang.org/x/term" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/registry" +) + +// NewDefault creates a production Factory with cached closures. +func NewDefault() *Factory { + f := &Factory{ + Keychain: keychain.Default(), + } + f.IOStreams = &IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), + } + f.Config = cachedConfigFunc(f) + f.AuthConfig = cachedAuthConfigFunc(f) + f.HttpClient = cachedHttpClientFunc() + f.LarkClient = cachedLarkClientFunc(f) + return f +} + +func cachedConfigFunc(f *Factory) func() (*core.CliConfig, error) { + return sync.OnceValues(func() (*core.CliConfig, error) { + cfg, err := core.RequireConfig(f.Keychain) + if err != nil { + return cfg, err + } + registry.InitWithBrand(cfg.Brand) + return cfg, nil + }) +} + +func cachedAuthConfigFunc(f *Factory) func() (*core.CliConfig, error) { + return sync.OnceValues(func() (*core.CliConfig, error) { + return core.RequireAuth(f.Keychain) + }) +} + +// safeRedirectPolicy prevents credential headers from being forwarded +// when a response redirects to a different host (e.g. Lark API 302 → CDN). +// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host +// redirects; other headers like X-Cli-* pass through. +func safeRedirectPolicy(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + if len(via) > 0 && req.URL.Host != via[0].URL.Host { + req.Header.Del("Authorization") + req.Header.Del("X-Lark-MCP-UAT") + req.Header.Del("X-Lark-MCP-TAT") + } + return nil +} + +func cachedHttpClientFunc() func() (*http.Client, error) { + return sync.OnceValues(func() (*http.Client, error) { + var transport = http.DefaultTransport + transport = &RetryTransport{Base: transport} + transport = &SecurityHeaderTransport{Base: transport} + + transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor + client := &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + CheckRedirect: safeRedirectPolicy, + } + return client, nil + }) +} + +func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { + return sync.OnceValues(func() (*lark.Client, error) { + cfg, err := f.Config() + if err != nil { + return nil, err + } + opts := []lark.ClientOptionFunc{ + lark.WithLogLevel(larkcore.LogLevelError), + lark.WithHeaders(BaseSecurityHeaders()), + } + // Build SDK transport chain + var sdkTransport = http.DefaultTransport + sdkTransport = &UserAgentTransport{Base: sdkTransport} + sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport} + opts = append(opts, lark.WithHttpClient(&http.Client{ + Transport: sdkTransport, + CheckRedirect: safeRedirectPolicy, + })) + ep := core.ResolveEndpoints(cfg.Brand) + opts = append(opts, lark.WithOpenBaseUrl(ep.Open)) + client := lark.NewClient(cfg.AppID, cfg.AppSecret, opts...) + return client, nil + }) +} diff --git a/internal/cmdutil/factory_http_test.go b/internal/cmdutil/factory_http_test.go new file mode 100644 index 00000000..c27e9e69 --- /dev/null +++ b/internal/cmdutil/factory_http_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "testing" +) + +func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) { + fn := cachedHttpClientFunc() + + c1, err := fn() + if err != nil { + t.Fatalf("first call: %v", err) + } + if c1 == nil { + t.Fatal("first call returned nil") + } + + c2, err := fn() + if err != nil { + t.Fatalf("second call: %v", err) + } + if c1 != c2 { + t.Error("expected same *http.Client instance on second call (cache hit)") + } +} + +func TestCachedHttpClientFunc_HasTimeout(t *testing.T) { + fn := cachedHttpClientFunc() + c, _ := fn() + if c.Timeout == 0 { + t.Error("expected non-zero timeout") + } +} + +func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) { + fn := cachedHttpClientFunc() + c, _ := fn() + if c.CheckRedirect == nil { + t.Error("expected CheckRedirect to be set (safeRedirectPolicy)") + } +} diff --git a/internal/cmdutil/factory_test.go b/internal/cmdutil/factory_test.go new file mode 100644 index 00000000..9912bbce --- /dev/null +++ b/internal/cmdutil/factory_test.go @@ -0,0 +1,282 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/core" +) + +// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing. +func newCmdWithAsFlag(asValue string, changed bool) *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("as", "auto", "identity") + if changed { + _ = cmd.Flags().Set("as", asValue) + } + return cmd +} + +// --- ResolveAs tests --- + +func TestResolveAs_ExplicitAs(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := newCmdWithAsFlag("bot", true) + + got := f.ResolveAs(cmd, core.AsBot) + if got != core.AsBot { + t.Errorf("want bot, got %s", got) + } + if f.IdentityAutoDetected { + t.Error("IdentityAutoDetected should be false for explicit --as") + } + if f.ResolvedIdentity != core.AsBot { + t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity) + } +} + +func TestResolveAs_ExplicitAsUser(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := newCmdWithAsFlag("user", true) + + got := f.ResolveAs(cmd, core.AsUser) + if got != core.AsUser { + t.Errorf("want user, got %s", got) + } + if f.ResolvedIdentity != core.AsUser { + t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity) + } +} + +func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) { + // --as auto explicitly: should fall through to auto-detect + // Config has no UserOpenId → auto-detect returns bot + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := newCmdWithAsFlag("auto", true) + + got := f.ResolveAs(cmd, "auto") + if got != core.AsBot { + t.Errorf("want bot (auto-detect, no login), got %s", got) + } + if !f.IdentityAutoDetected { + t.Error("IdentityAutoDetected should be true for auto-detect path") + } +} + +func TestResolveAs_DefaultAs_FromConfig(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{ + AppID: "a", AppSecret: "s", + DefaultAs: "bot", + }) + cmd := newCmdWithAsFlag("auto", false) // --as not changed + + got := f.ResolveAs(cmd, "auto") + if got != core.AsBot { + t.Errorf("want bot (from default-as config), got %s", got) + } + if f.IdentityAutoDetected { + t.Error("IdentityAutoDetected should be false for default-as path") + } +} + +func TestResolveAs_DefaultAs_FromEnv(t *testing.T) { + os.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user") + defer os.Unsetenv("LARKSUITE_CLI_DEFAULT_AS") + + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := newCmdWithAsFlag("auto", false) + + got := f.ResolveAs(cmd, "auto") + if got != core.AsUser { + t.Errorf("want user (from env), got %s", got) + } +} + +func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) { + // default-as = "auto" should fall through to auto-detect + f, _, _, _ := TestFactory(t, &core.CliConfig{ + AppID: "a", AppSecret: "s", + DefaultAs: "auto", + }) + cmd := newCmdWithAsFlag("auto", false) + + got := f.ResolveAs(cmd, "auto") + // No UserOpenId → auto-detect returns bot + if got != core.AsBot { + t.Errorf("want bot (auto-detect), got %s", got) + } + if !f.IdentityAutoDetected { + t.Error("IdentityAutoDetected should be true") + } +} + +func TestResolveAs_NilCmd_AutoDetect(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + + got := f.ResolveAs(nil, "auto") + if got != core.AsBot { + t.Errorf("want bot, got %s", got) + } +} + +// --- CheckIdentity tests --- + +func TestCheckIdentity_Supported(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + + err := f.CheckIdentity(core.AsBot, []string{"bot", "user"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.ResolvedIdentity != core.AsBot { + t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity) + } +} + +func TestCheckIdentity_Supported_UserOnly(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + + err := f.CheckIdentity(core.AsUser, []string{"user"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.ResolvedIdentity != core.AsUser { + t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity) + } +} + +func TestCheckIdentity_Unsupported_Explicit(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f.IdentityAutoDetected = false // explicit --as + + err := f.CheckIdentity(core.AsUser, []string{"bot"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--as user is not supported") { + t.Errorf("unexpected error message: %v", err) + } + if !strings.Contains(err.Error(), "bot") { + t.Errorf("error should mention supported identity: %v", err) + } +} + +func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f.IdentityAutoDetected = true + + err := f.CheckIdentity(core.AsUser, []string{"bot"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "resolved identity") { + t.Errorf("expected 'resolved identity' in error, got: %v", err) + } + if !strings.Contains(err.Error(), "hint: use --as bot") { + t.Errorf("expected hint in error, got: %v", err) + } +} + +// --- ResolveConfig tests --- + +func TestResolveConfig_Bot(t *testing.T) { + cfg := &core.CliConfig{AppID: "a", AppSecret: "s"} + f, _, _, _ := TestFactory(t, cfg) + + got, err := f.ResolveConfig(core.AsBot) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AppID != "a" { + t.Errorf("want AppID a, got %s", got.AppID) + } +} + +func TestResolveConfig_User(t *testing.T) { + cfg := &core.CliConfig{AppID: "a", AppSecret: "s"} + f, _, _, _ := TestFactory(t, cfg) + + got, err := f.ResolveConfig(core.AsUser) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AppID != "a" { + t.Errorf("want AppID a, got %s", got.AppID) + } +} + +// --- autoDetectIdentity tests --- + +func TestAutoDetectIdentity_NoUserOpenId(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + got := f.autoDetectIdentity() + if got != core.AsBot { + t.Errorf("want bot (no UserOpenId), got %s", got) + } +} + +func TestAutoDetectIdentity_ConfigError(t *testing.T) { + f := &Factory{ + Config: func() (*core.CliConfig, error) { + return nil, os.ErrNotExist + }, + } + got := f.autoDetectIdentity() + if got != core.AsBot { + t.Errorf("want bot (config error), got %s", got) + } +} + +// --- NewAPIClient / NewAPIClientWithConfig tests --- + +func TestNewAPIClient(t *testing.T) { + cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark} + f, _, _, _ := TestFactory(t, cfg) + + ac, err := f.NewAPIClient() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ac.Config.AppID != "a" { + t.Errorf("want AppID a, got %s", ac.Config.AppID) + } +} + +func TestNewAPIClientWithConfig(t *testing.T) { + cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark} + f, _, _, _ := TestFactory(t, cfg) + + ac, err := f.NewAPIClientWithConfig(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ac.Config.AppID != "a" { + t.Errorf("want AppID a, got %s", ac.Config.AppID) + } + if ac.SDK == nil { + t.Error("SDK should not be nil") + } + if ac.HTTP == nil { + t.Error("HTTP should not be nil") + } +} + +func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) { + cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark} + f, _, _, _ := TestFactory(t, cfg) + f.IOStreams = nil + + ac, err := f.NewAPIClientWithConfig(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ac == nil { + t.Fatal("expected non-nil APIClient") + } +} diff --git a/internal/cmdutil/identity.go b/internal/cmdutil/identity.go new file mode 100644 index 00000000..040f9e62 --- /dev/null +++ b/internal/cmdutil/identity.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "fmt" + "io" + + "github.com/larksuite/cli/internal/core" +) + +// AccessTokensToIdentities converts from_meta accessTokens (e.g. ["tenant", "user"]) +// to CLI identity names (e.g. ["bot", "user"]). +func AccessTokensToIdentities(tokens []interface{}) []string { + var identities []string + for _, t := range tokens { + if ts, ok := t.(string); ok { + if ts == "tenant" { + identities = append(identities, "bot") + } else { + identities = append(identities, ts) + } + } + } + return identities +} + +// PrintIdentity outputs the current identity to stderr so callers (including AI agents) +// can see which identity is being used for the API call. +func PrintIdentity(w io.Writer, as core.Identity, config *core.CliConfig, autoDetected bool) { + if as.IsBot() { + if autoDetected { + fmt.Fprintln(w, "[identity: bot (auto — not logged in; `auth login` for user identity)]") + } else { + fmt.Fprintln(w, "[identity: bot]") + } + } else if config != nil && config.UserOpenId != "" { + fmt.Fprintf(w, "[identity: user (%s)]\n", config.UserOpenId) + } else { + fmt.Fprintln(w, "[identity: user]") + } +} diff --git a/internal/cmdutil/identity_test.go b/internal/cmdutil/identity_test.go new file mode 100644 index 00000000..d65e2b08 --- /dev/null +++ b/internal/cmdutil/identity_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "bytes" + "strings" + "testing" + + "github.com/larksuite/cli/internal/core" +) + +func TestAccessTokensToIdentities(t *testing.T) { + tests := []struct { + name string + tokens []interface{} + want []string + }{ + { + name: "tenant becomes bot", + tokens: []interface{}{"tenant"}, + want: []string{"bot"}, + }, + { + name: "user stays user", + tokens: []interface{}{"user"}, + want: []string{"user"}, + }, + { + name: "tenant and user", + tokens: []interface{}{"tenant", "user"}, + want: []string{"bot", "user"}, + }, + { + name: "empty list", + tokens: []interface{}{}, + want: nil, + }, + { + name: "non-string values skipped", + tokens: []interface{}{"tenant", 42, "user"}, + want: []string{"bot", "user"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AccessTokensToIdentities(tt.tokens) + if len(got) != len(tt.want) { + t.Fatalf("len: want %d, got %d (%v)", len(tt.want), len(got), got) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("[%d] want %s, got %s", i, tt.want[i], got[i]) + } + } + }) + } +} + +func TestPrintIdentity_BotExplicit(t *testing.T) { + var buf bytes.Buffer + PrintIdentity(&buf, core.AsBot, nil, false) + if !strings.Contains(buf.String(), "[identity: bot]") { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestPrintIdentity_BotAutoDetected(t *testing.T) { + var buf bytes.Buffer + PrintIdentity(&buf, core.AsBot, nil, true) + if !strings.Contains(buf.String(), "auto") { + t.Errorf("expected auto hint, got: %s", buf.String()) + } +} + +func TestPrintIdentity_UserWithOpenId(t *testing.T) { + var buf bytes.Buffer + cfg := &core.CliConfig{UserOpenId: "ou_abc123"} + PrintIdentity(&buf, core.AsUser, cfg, false) + if !strings.Contains(buf.String(), "ou_abc123") { + t.Errorf("expected UserOpenId in output, got: %s", buf.String()) + } +} + +func TestPrintIdentity_UserWithoutOpenId(t *testing.T) { + var buf bytes.Buffer + PrintIdentity(&buf, core.AsUser, &core.CliConfig{}, false) + if !strings.Contains(buf.String(), "[identity: user]") { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestPrintIdentity_UserNilConfig(t *testing.T) { + var buf bytes.Buffer + PrintIdentity(&buf, core.AsUser, nil, false) + if !strings.Contains(buf.String(), "[identity: user]") { + t.Errorf("unexpected output: %s", buf.String()) + } +} diff --git a/internal/cmdutil/iostreams.go b/internal/cmdutil/iostreams.go new file mode 100644 index 00000000..76068e05 --- /dev/null +++ b/internal/cmdutil/iostreams.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import "io" + +// IOStreams provides the standard input/output/error streams. +// Commands should use these instead of os.Stdin/Stdout/Stderr +// to enable testing and output capture. +type IOStreams struct { + In io.Reader + Out io.Writer + ErrOut io.Writer + IsTerminal bool +} diff --git a/internal/cmdutil/json.go b/internal/cmdutil/json.go new file mode 100644 index 00000000..6a162c4e --- /dev/null +++ b/internal/cmdutil/json.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "encoding/json" + + "github.com/larksuite/cli/internal/output" +) + +// ParseOptionalBody parses --data JSON for methods that accept a request body. +// Returns (nil, nil) if the method has no body or data is empty. +func ParseOptionalBody(httpMethod, data string) (interface{}, error) { + switch httpMethod { + case "POST", "PUT", "PATCH", "DELETE": + default: + return nil, nil + } + if data == "" { + return nil, nil + } + var body interface{} + if err := json.Unmarshal([]byte(data), &body); err != nil { + return nil, output.ErrValidation("--data invalid JSON format") + } + return body, nil +} + +// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty. +func ParseJSONMap(input, label string) (map[string]any, error) { + if input == "" { + return map[string]any{}, nil + } + var result map[string]any + if err := json.Unmarshal([]byte(input), &result); err != nil { + return nil, output.ErrValidation("%s invalid format, expected JSON object", label) + } + return result, nil +} diff --git a/internal/cmdutil/json_test.go b/internal/cmdutil/json_test.go new file mode 100644 index 00000000..e88218a1 --- /dev/null +++ b/internal/cmdutil/json_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import "testing" + +func TestParseOptionalBody(t *testing.T) { + tests := []struct { + name string + method string + data string + wantNil bool + wantErr bool + }{ + {"GET ignored", "GET", `{"a":1}`, true, false}, + {"POST empty data", "POST", "", true, false}, + {"POST valid", "POST", `{"key":"val"}`, false, false}, + {"PUT valid", "PUT", `[1,2,3]`, false, false}, + {"PATCH valid", "PATCH", `"hello"`, false, false}, + {"DELETE valid", "DELETE", `{"id":"1"}`, false, false}, + {"POST invalid json", "POST", `{bad}`, true, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseOptionalBody(tt.method, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantNil && got != nil { + t.Errorf("ParseOptionalBody() = %v, want nil", got) + } + if !tt.wantNil && !tt.wantErr && got == nil { + t.Error("ParseOptionalBody() = nil, want non-nil") + } + }) + } +} + +func TestParseJSONMap(t *testing.T) { + tests := []struct { + name string + input string + label string + wantLen int + wantErr bool + }{ + {"empty input", "", "--params", 0, false}, + {"valid json", `{"a":"1","b":"2"}`, "--params", 2, false}, + {"invalid json", `{bad}`, "--params", 0, true}, + {"json array", `[1,2]`, "--data", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseJSONMap(tt.input, tt.label) + if (err != nil) != tt.wantErr { + t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(got) != tt.wantLen { + t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen) + } + }) + } +} diff --git a/internal/cmdutil/retry_transport_test.go b/internal/cmdutil/retry_transport_test.go new file mode 100644 index 00000000..a10782ac --- /dev/null +++ b/internal/cmdutil/retry_transport_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "io" + "net/http" + "strings" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestRetryTransport_NoRetry(t *testing.T) { + calls := 0 + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil + }) + rt := &RetryTransport{Base: base, MaxRetries: 0} + req, _ := http.NewRequest("GET", "http://example.com/test", nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if calls != 1 { + t.Errorf("expected 1 call, got %d", calls) + } +} + +func TestRetryTransport_RetryOn500(t *testing.T) { + calls := 0 + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + if calls < 3 { + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil + }) + rt := &RetryTransport{Base: base, MaxRetries: 3, Delay: 1 * time.Millisecond} + req, _ := http.NewRequest("GET", "http://example.com/test", nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200 after retries, got %d", resp.StatusCode) + } + if calls != 3 { + t.Errorf("expected 3 calls, got %d", calls) + } +} + +func TestRetryTransport_DefaultNoRetry(t *testing.T) { + calls := 0 + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil + }) + rt := &RetryTransport{Base: base} // default MaxRetries=0 + req, _ := http.NewRequest("GET", "http://example.com/test", nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 500 { + t.Errorf("expected 500 with no retries, got %d", resp.StatusCode) + } + if calls != 1 { + t.Errorf("expected 1 call with default config, got %d", calls) + } +} diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go new file mode 100644 index 00000000..15745264 --- /dev/null +++ b/internal/cmdutil/secheader.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "context" + "net/http" + + "github.com/larksuite/cli/internal/build" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +const ( + HeaderSource = "X-Cli-Source" + HeaderVersion = "X-Cli-Version" + HeaderShortcut = "X-Cli-Shortcut" + HeaderExecutionId = "X-Cli-Execution-Id" + + SourceValue = "lark-cli" + + HeaderUserAgent = "User-Agent" +) + +// UserAgentValue returns the User-Agent value: "lark-cli/{version}". +func UserAgentValue() string { + return SourceValue + "/" + build.Version +} + +// BaseSecurityHeaders returns headers that every request must carry. +func BaseSecurityHeaders() http.Header { + h := make(http.Header) + h.Set(HeaderSource, SourceValue) + h.Set(HeaderVersion, build.Version) + h.Set(HeaderUserAgent, UserAgentValue()) + return h +} + +// ── Context utilities ── + +type ctxKey string + +const ( + ctxShortcutName ctxKey = "lark:shortcut-name" + ctxExecutionId ctxKey = "lark:execution-id" +) + +// ContextWithShortcut injects shortcut name and execution ID into the context. +func ContextWithShortcut(ctx context.Context, name, executionId string) context.Context { + ctx = context.WithValue(ctx, ctxShortcutName, name) + ctx = context.WithValue(ctx, ctxExecutionId, executionId) + return ctx +} + +// ShortcutNameFromContext extracts the shortcut name from the context. +func ShortcutNameFromContext(ctx context.Context) (string, bool) { + v, ok := ctx.Value(ctxShortcutName).(string) + return v, ok && v != "" +} + +// ExecutionIdFromContext extracts the execution ID from the context. +func ExecutionIdFromContext(ctx context.Context) (string, bool) { + v, ok := ctx.Value(ctxExecutionId).(string) + return v, ok && v != "" +} + +// ShortcutHeaderOpts extracts Shortcut info from the context and returns a +// RequestOptionFunc that injects the corresponding headers into SDK requests. +// Returns nil if the context has no Shortcut info. +func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc { + name, ok := ShortcutNameFromContext(ctx) + if !ok { + return nil + } + h := make(http.Header) + h.Set(HeaderShortcut, name) + if eid, ok := ExecutionIdFromContext(ctx); ok { + h.Set(HeaderExecutionId, eid) + } + return larkcore.WithHeaders(h) +} diff --git a/internal/cmdutil/testing.go b/internal/cmdutil/testing.go new file mode 100644 index 00000000..e32b17f0 --- /dev/null +++ b/internal/cmdutil/testing.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "bytes" + "net/http" + "testing" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +// noopKeychain is a no-op KeychainAccess for tests that don't need keychain. +type noopKeychain struct{} + +func (n *noopKeychain) Get(service, account string) (string, error) { return "", nil } +func (n *noopKeychain) Set(service, account, value string) error { return nil } +func (n *noopKeychain) Remove(service, account string) error { return nil } + +// TestFactory creates a Factory for testing. +// Returns (factory, stdout buffer, stderr buffer, http mock registry). +func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + + reg := &httpmock.Registry{} + t.Cleanup(func() { reg.Verify(t) }) + + stdoutBuf := &bytes.Buffer{} + stderrBuf := &bytes.Buffer{} + + mockClient := httpmock.NewClient(reg) + // SDK mock client wraps the mock transport with UserAgentTransport + // so that User-Agent overrides the SDK default (oapi-sdk-go/v3.x.x). + sdkMockClient := &http.Client{ + Transport: &UserAgentTransport{Base: reg}, + } + + // Build a test LarkClient using the config + var testLarkClient *lark.Client + if config != nil && config.AppID != "" { + opts := []lark.ClientOptionFunc{ + lark.WithLogLevel(larkcore.LogLevelError), + lark.WithHttpClient(sdkMockClient), + lark.WithHeaders(BaseSecurityHeaders()), + } + if config.Brand != "" { + opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(config.Brand))) + } + testLarkClient = lark.NewClient(config.AppID, config.AppSecret, opts...) + } + + f := &Factory{ + Config: func() (*core.CliConfig, error) { return config, nil }, + AuthConfig: func() (*core.CliConfig, error) { return config, nil }, + HttpClient: func() (*http.Client, error) { return mockClient, nil }, + LarkClient: func() (*lark.Client, error) { return testLarkClient, nil }, + IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf}, + Keychain: &noopKeychain{}, + } + return f, stdoutBuf, stderrBuf, reg +} diff --git a/internal/cmdutil/testing_test.go b/internal/cmdutil/testing_test.go new file mode 100644 index 00000000..e12d5370 --- /dev/null +++ b/internal/cmdutil/testing_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func TestTestFactory_ReplacesGlobals(t *testing.T) { + config := &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", + Brand: core.BrandFeishu, + } + + f, stdout, stderr, reg := TestFactory(t, config) + + // Factory should return our config + got, err := f.Config() + if err != nil { + t.Fatalf("Config() error: %v", err) + } + if got.AppID != "test-app" { + t.Errorf("want AppID test-app, got %s", got.AppID) + } + + // IOStreams.Out/ErrOut should be our buffers + output.PrintJson(f.IOStreams.Out, map[string]string{"key": "value"}) + if !strings.Contains(stdout.String(), `"key"`) { + t.Error("output.PrintJson did not write to test stdout") + } + + output.PrintError(f.IOStreams.ErrOut, "test error") + if !strings.Contains(stderr.String(), "test error") { + t.Error("output.PrintError did not write to test stderr") + } + + // Register a stub so Verify passes + reg.Register(&httpmock.Stub{ + URL: "/test", + Body: "ok", + }) + // Use the stub via Factory HttpClient + httpClient, err := f.HttpClient() + if err != nil { + t.Fatalf("HttpClient() error: %v", err) + } + baseURL := core.ResolveOpenBaseURL(core.BrandFeishu) + req, _ := http.NewRequest("GET", baseURL+"/test", nil) + resp, err := httpClient.Do(req) + if err != nil { + t.Fatalf("HttpClient request error: %v", err) + } + resp.Body.Close() +} diff --git a/internal/cmdutil/theme.go b/internal/cmdutil/theme.go new file mode 100644 index 00000000..276126d0 --- /dev/null +++ b/internal/cmdutil/theme.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// ThemeFeishu returns a huh theme with Feishu brand colors. +func ThemeFeishu() *huh.Theme { + t := huh.ThemeBase() + + var ( + blue = lipgloss.Color("#1456F0") // 标题、边框 + teal = lipgloss.Color("#33D6C0") // 选择器、光标、输入提示 + cyan = lipgloss.Color("#3EC3C0") // 选中项 + orange = lipgloss.Color("#FF811A") // 按钮高亮 + magenta = lipgloss.Color("#CC398C") // 错误 + text = lipgloss.AdaptiveColor{Light: "#1F2329", Dark: "#E8E8E8"} + subtext = lipgloss.AdaptiveColor{Light: "#8F959E", Dark: "#8F959E"} + btnBg = lipgloss.AdaptiveColor{Light: "#EEF3FF", Dark: "#2B3A5C"} + ) + + t.Focused.Base = t.Focused.Base.BorderForeground(blue) + t.Focused.Card = t.Focused.Base + t.Focused.Title = t.Focused.Title.Foreground(blue).Bold(true) + t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(blue).Bold(true) + t.Focused.Description = t.Focused.Description.Foreground(subtext) + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(magenta) + t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(magenta) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(teal) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(teal) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(teal) + t.Focused.Option = t.Focused.Option.Foreground(text) + t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(teal) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(cyan) + t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(cyan).SetString("✓ ") + t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(text) + t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(subtext).SetString("• ") + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(orange).Bold(true) + t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(btnBg) + + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(teal) + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtext) + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(teal) + + t.Blurred = t.Focused + t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Card = t.Blurred.Base + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + return t +} diff --git a/internal/cmdutil/tips.go b/internal/cmdutil/tips.go new file mode 100644 index 00000000..2cf944d5 --- /dev/null +++ b/internal/cmdutil/tips.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "encoding/json" + + "github.com/spf13/cobra" +) + +const tipsAnnotationKey = "tips" + +// SetTips sets the tips for a command (stored as JSON in Annotations). +func SetTips(cmd *cobra.Command, tips []string) { + if len(tips) == 0 { + return + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + data, _ := json.Marshal(tips) + cmd.Annotations[tipsAnnotationKey] = string(data) +} + +// AddTips appends tips to a command (merges with existing). +func AddTips(cmd *cobra.Command, tips ...string) { + existing := GetTips(cmd) + SetTips(cmd, append(existing, tips...)) +} + +// GetTips retrieves the tips from a command's annotations. +func GetTips(cmd *cobra.Command) []string { + if cmd.Annotations == nil { + return nil + } + raw, ok := cmd.Annotations[tipsAnnotationKey] + if !ok { + return nil + } + var tips []string + err := json.Unmarshal([]byte(raw), &tips) + if err != nil { + return nil + } + return tips +} diff --git a/internal/cmdutil/tips_test.go b/internal/cmdutil/tips_test.go new file mode 100644 index 00000000..1ee88545 --- /dev/null +++ b/internal/cmdutil/tips_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestSetTipsAndGetTips(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + tips := []string{"tip one", "tip two"} + SetTips(cmd, tips) + + got := GetTips(cmd) + if len(got) != 2 || got[0] != "tip one" || got[1] != "tip two" { + t.Fatalf("expected %v, got %v", tips, got) + } +} + +func TestSetTipsEmpty(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + SetTips(cmd, nil) + + if cmd.Annotations != nil { + t.Fatal("expected nil annotations for empty tips") + } +} + +func TestGetTipsNoAnnotations(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + got := GetTips(cmd) + if got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestAddTips(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + SetTips(cmd, []string{"first"}) + AddTips(cmd, "second", "third") + + got := GetTips(cmd) + if len(got) != 3 || got[0] != "first" || got[1] != "second" || got[2] != "third" { + t.Fatalf("expected [first second third], got %v", got) + } +} + +func TestAddTipsToEmpty(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + AddTips(cmd, "only") + + got := GetTips(cmd) + if len(got) != 1 || got[0] != "only" { + t.Fatalf("expected [only], got %v", got) + } +} diff --git a/internal/cmdutil/transport.go b/internal/cmdutil/transport.go new file mode 100644 index 00000000..bbdc0d3e --- /dev/null +++ b/internal/cmdutil/transport.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "net/http" + "time" +) + +// RetryTransport is an http.RoundTripper that retries on 5xx responses +// and network errors. MaxRetries defaults to 0 (no retries). +type RetryTransport struct { + Base http.RoundTripper + MaxRetries int + Delay time.Duration // base delay for exponential backoff; defaults to 500ms +} + +func (t *RetryTransport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + +func (t *RetryTransport) delay() time.Duration { + if t.Delay > 0 { + return t.Delay + } + return 500 * time.Millisecond +} + +// RoundTrip implements http.RoundTripper. +func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.base().RoundTrip(req) + if t.MaxRetries <= 0 { + return resp, err + } + + for attempt := 0; attempt < t.MaxRetries; attempt++ { + if err == nil && resp.StatusCode < 500 { + return resp, nil + } + // Clone request for retry + cloned := req.Clone(req.Context()) + if req.Body != nil && req.GetBody != nil { + cloned.Body, _ = req.GetBody() + } + delay := t.delay() * (1 << uint(attempt)) + time.Sleep(delay) + resp, err = t.base().RoundTrip(cloned) + } + return resp, err +} + +// UserAgentTransport is an http.RoundTripper that sets the User-Agent header. +// Used in the SDK transport chain to override the SDK's default User-Agent. +type UserAgentTransport struct { + Base http.RoundTripper +} + +func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set(HeaderUserAgent, UserAgentValue()) + if t.Base != nil { + return t.Base.RoundTrip(req) + } + return http.DefaultTransport.RoundTrip(req) +} + +// SecurityHeaderTransport is an http.RoundTripper that injects CLI security +// headers into every request. Shortcut headers are read from the request context. +type SecurityHeaderTransport struct { + Base http.RoundTripper +} + +func (t *SecurityHeaderTransport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + +// RoundTrip implements http.RoundTripper. +func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + for k, vs := range BaseSecurityHeaders() { + for _, v := range vs { + req.Header.Set(k, v) + } + } + // Shortcut headers are propagated via context (see section 5.6 of the design doc). + if name, ok := ShortcutNameFromContext(req.Context()); ok { + req.Header.Set(HeaderShortcut, name) + } + if eid, ok := ExecutionIdFromContext(req.Context()); ok { + req.Header.Set(HeaderExecutionId, eid) + } + return t.base().RoundTrip(req) +} diff --git a/internal/core/config.go b/internal/core/config.go new file mode 100644 index 00000000..ced1e27b --- /dev/null +++ b/internal/core/config.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/validate" +) + +// Identity represents the caller identity for API requests. +type Identity string + +const ( + AsUser Identity = "user" + AsBot Identity = "bot" +) + +// IsBot returns true if the identity is bot. +func (id Identity) IsBot() bool { return id == AsBot } + +// AppUser is a logged-in user record stored in config. +type AppUser struct { + UserOpenId string `json:"userOpenId"` + UserName string `json:"userName"` +} + +// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved). +type AppConfig struct { + AppId string `json:"appId"` + AppSecret SecretInput `json:"appSecret"` + Brand LarkBrand `json:"brand"` + Lang string `json:"lang,omitempty"` + DefaultAs string `json:"defaultAs,omitempty"` // "user" | "bot" | "auto" + Users []AppUser `json:"users"` +} + +// MultiAppConfig is the multi-app config file format. +type MultiAppConfig struct { + Apps []AppConfig `json:"apps"` +} + +// CliConfig is the resolved single-app config used by downstream code. +type CliConfig struct { + AppID string + AppSecret string + Brand LarkBrand + DefaultAs string // "user" | "bot" | "auto" | "" (from config file) + UserOpenId string + UserName string +} + +// GetConfigDir returns the config directory path. +// If the home directory cannot be determined, it falls back to a relative path +// and prints a warning to stderr. +func GetConfigDir() string { + if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" { + return dir + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) + } + return filepath.Join(home, ".lark-cli") +} + +// GetConfigPath returns the config file path. +func GetConfigPath() string { + return filepath.Join(GetConfigDir(), "config.json") +} + +// LoadMultiAppConfig loads multi-app config from disk. +func LoadMultiAppConfig() (*MultiAppConfig, error) { + data, err := os.ReadFile(GetConfigPath()) + if err != nil { + return nil, err + } + + var multi MultiAppConfig + if err := json.Unmarshal(data, &multi); err != nil { + return nil, fmt.Errorf("invalid config format: %w", err) + } + if len(multi.Apps) == 0 { + return nil, fmt.Errorf("invalid config format: no apps") + } + return &multi, nil +} + +// SaveMultiAppConfig saves config to disk. +func SaveMultiAppConfig(config *MultiAppConfig) error { + dir := GetConfigDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600) +} + +// RequireConfig loads the single-app config. Takes Apps[0] directly. +func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) { + raw, err := LoadMultiAppConfig() + if err != nil || raw == nil || len(raw.Apps) == 0 { + return nil, &ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."} + } + app := raw.Apps[0] + secret, err := ResolveSecretInput(app.AppSecret, kc) + if err != nil { + return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()} + } + cfg := &CliConfig{ + AppID: app.AppId, + AppSecret: secret, + Brand: app.Brand, + DefaultAs: app.DefaultAs, + } + if len(app.Users) > 0 { + cfg.UserOpenId = app.Users[0].UserOpenId + cfg.UserName = app.Users[0].UserName + } + return cfg, nil +} + +// RequireAuth loads config and ensures a user is logged in. +func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) { + cfg, err := RequireConfig(kc) + if err != nil { + return nil, err + } + if cfg.UserOpenId == "" { + return nil, &ConfigError{Code: 3, Type: "auth", Message: "not logged in", Hint: "run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login."} + } + return cfg, nil +} diff --git a/internal/core/config_test.go b/internal/core/config_test.go new file mode 100644 index 00000000..1c9ac449 --- /dev/null +++ b/internal/core/config_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "encoding/json" + "testing" +) + +func TestAppConfig_LangSerialization(t *testing.T) { + app := AppConfig{ + AppId: "cli_test", AppSecret: PlainSecret("secret"), + Brand: BrandFeishu, Lang: "en", Users: []AppUser{}, + } + data, err := json.Marshal(app) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got AppConfig + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Lang != "en" { + t.Errorf("Lang = %q, want %q", got.Lang, "en") + } +} + +func TestAppConfig_LangOmitEmpty(t *testing.T) { + app := AppConfig{ + AppId: "cli_test", AppSecret: PlainSecret("secret"), + Brand: BrandFeishu, Users: []AppUser{}, + } + data, err := json.Marshal(app) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Lang should be omitted when empty + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + if _, exists := raw["lang"]; exists { + t.Error("expected lang to be omitted when empty") + } +} + +func TestMultiAppConfig_RoundTrip(t *testing.T) { + config := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_test", AppSecret: PlainSecret("s"), + Brand: BrandLark, Lang: "zh", Users: []AppUser{}, + }}, + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got MultiAppConfig + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.Apps) != 1 { + t.Fatalf("expected 1 app, got %d", len(got.Apps)) + } + if got.Apps[0].Lang != "zh" { + t.Errorf("Lang = %q, want %q", got.Apps[0].Lang, "zh") + } + if got.Apps[0].Brand != BrandLark { + t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark) + } +} diff --git a/internal/core/errors.go b/internal/core/errors.go new file mode 100644 index 00000000..14d443a0 --- /dev/null +++ b/internal/core/errors.go @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import "fmt" + +// ConfigError is a structured error from config resolution. +// It carries enough information for main.go to convert it into an output.ExitError. +type ConfigError struct { + Code int // exit code: 2=validation, 3=auth + Type string // "config" or "auth" + Message string + Hint string +} + +func (e *ConfigError) Error() string { + if e.Hint != "" { + return fmt.Sprintf("%s\n %s", e.Message, e.Hint) + } + return e.Message +} diff --git a/internal/core/secret.go b/internal/core/secret.go new file mode 100644 index 00000000..a488e5dc --- /dev/null +++ b/internal/core/secret.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "encoding/json" + "fmt" +) + +// --------------------------------------------------------------------------- +// SecretRef — external secret reference +// --------------------------------------------------------------------------- + +// SecretRef references a secret stored externally. +type SecretRef struct { + Source string `json:"source"` // "file" | "keychain" + Provider string `json:"provider,omitempty"` // optional, reserved + ID string `json:"id"` // env var name / file path / command / keychain key +} + +// --------------------------------------------------------------------------- +// SecretInput — union type: plain string or SecretRef +// --------------------------------------------------------------------------- + +// SecretInput represents a secret value: either a plain string or a SecretRef object. +type SecretInput struct { + Plain string // non-empty for plain string values + Ref *SecretRef // non-nil for SecretRef values +} + +// PlainSecret creates a SecretInput from a plain string. +func PlainSecret(s string) SecretInput { + return SecretInput{Plain: s} +} + +// IsZero returns true if the SecretInput has no value. +func (s SecretInput) IsZero() bool { + return s.Plain == "" && s.Ref == nil +} + +// IsSecretRef returns true if this is a SecretRef object (env/file/keychain). +func (s SecretInput) IsSecretRef() bool { + return s.Ref != nil +} + +// IsPlain returns true if this is a plain text string (not a SecretRef). +func (s SecretInput) IsPlain() bool { + return s.Ref == nil +} + +// MarshalJSON serializes SecretInput: plain string → JSON string, SecretRef → JSON object. +func (s SecretInput) MarshalJSON() ([]byte, error) { + if s.Ref != nil { + return json.Marshal(s.Ref) + } + return json.Marshal(s.Plain) +} + +// UnmarshalJSON deserializes SecretInput from either a JSON string or a SecretRef object. +func (s *SecretInput) UnmarshalJSON(data []byte) error { + // Try string first + var plain string + if err := json.Unmarshal(data, &plain); err == nil { + s.Plain = plain + s.Ref = nil + return nil + } + // Try SecretRef object + var ref SecretRef + if err := json.Unmarshal(data, &ref); err == nil && isValidSource(ref.Source) && ref.ID != "" { + s.Ref = &ref + s.Plain = "" + return nil + } + return fmt.Errorf("appSecret must be a string or {source, id} object") +} + +// ValidSecretSources is the set of recognized SecretRef sources. +var ValidSecretSources = map[string]bool{ + "file": true, "keychain": true, +} + +func isValidSource(source string) bool { + return ValidSecretSources[source] +} diff --git a/internal/core/secret_resolve.go b/internal/core/secret_resolve.go new file mode 100644 index 00000000..6e7921d3 --- /dev/null +++ b/internal/core/secret_resolve.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "fmt" + "os" + "strings" + + "github.com/larksuite/cli/internal/keychain" +) + +const secretKeyPrefix = "appsecret:" + +func secretAccountKey(appId string) string { + return secretKeyPrefix + appId +} + +// ResolveSecretInput resolves a SecretInput to a plain string. +// SecretRef objects are resolved by source (file / keychain). +func ResolveSecretInput(s SecretInput, kc keychain.KeychainAccess) (string, error) { + if s.Ref == nil { + return s.Plain, nil + } + switch s.Ref.Source { + case "file": + data, err := os.ReadFile(s.Ref.ID) + if err != nil { + return "", fmt.Errorf("failed to read secret file %s: %w", s.Ref.ID, err) + } + return strings.TrimSpace(string(data)), nil + case "keychain": + return kc.Get(keychain.LarkCliService, s.Ref.ID) + default: + return "", fmt.Errorf("unknown secret source: %s", s.Ref.Source) + } +} + +// ForStorage determines how to store a secret in config.json. +// - SecretRef → preserved as-is +// - Plain text → stored in keychain, returns keychain SecretRef +// Returns error if keychain is unavailable (no silent plaintext fallback). +func ForStorage(appId string, input SecretInput, kc keychain.KeychainAccess) (SecretInput, error) { + if !input.IsPlain() { + return input, nil // SecretRef → keep as-is + } + key := secretAccountKey(appId) + if err := kc.Set(keychain.LarkCliService, key, input.Plain); err != nil { + return SecretInput{}, fmt.Errorf("keychain unavailable: %w\nhint: use file: reference in config to bypass keychain", err) + } + return SecretInput{Ref: &SecretRef{Source: "keychain", ID: key}}, nil +} + +// RemoveSecretStore cleans up keychain entries when an app is removed. +// Errors are intentionally ignored — cleanup is best-effort. +func RemoveSecretStore(input SecretInput, kc keychain.KeychainAccess) { + if input.IsSecretRef() && input.Ref.Source == "keychain" { + _ = kc.Remove(keychain.LarkCliService, input.Ref.ID) + } +} diff --git a/internal/core/types.go b/internal/core/types.go new file mode 100644 index 00000000..4c21c259 --- /dev/null +++ b/internal/core/types.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +// LarkBrand represents the Lark platform brand. +// "feishu" targets China-mainland, "lark" targets international. +// Any other string is treated as a custom base URL. +type LarkBrand string + +const ( + BrandFeishu LarkBrand = "feishu" + BrandLark LarkBrand = "lark" +) + +// Endpoints holds resolved endpoint URLs for different Lark services. +type Endpoints struct { + Open string // e.g. "https://open.feishu.cn" + Accounts string // e.g. "https://accounts.feishu.cn" + MCP string // e.g. "https://mcp.feishu.cn" +} + +// ResolveEndpoints resolves endpoint URLs based on brand. +func ResolveEndpoints(brand LarkBrand) Endpoints { + switch brand { + case BrandLark: + return Endpoints{ + Open: "https://open.larksuite.com", + Accounts: "https://accounts.larksuite.com", + MCP: "https://mcp.larksuite.com", + } + default: + return Endpoints{ + Open: "https://open.feishu.cn", + Accounts: "https://accounts.feishu.cn", + MCP: "https://mcp.feishu.cn", + } + } +} + +// ResolveOpenBaseURL returns the Open API base URL for the given brand. +func ResolveOpenBaseURL(brand LarkBrand) string { + return ResolveEndpoints(brand).Open +} diff --git a/internal/core/types_test.go b/internal/core/types_test.go new file mode 100644 index 00000000..839b5b55 --- /dev/null +++ b/internal/core/types_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import "testing" + +func TestResolveEndpoints_Feishu(t *testing.T) { + ep := ResolveEndpoints(BrandFeishu) + if ep.Open != "https://open.feishu.cn" { + t.Errorf("Open = %q, want feishu.cn", ep.Open) + } + if ep.Accounts != "https://accounts.feishu.cn" { + t.Errorf("Accounts = %q, want feishu.cn", ep.Accounts) + } + if ep.MCP != "https://mcp.feishu.cn" { + t.Errorf("MCP = %q, want feishu.cn", ep.MCP) + } +} + +func TestResolveEndpoints_Lark(t *testing.T) { + ep := ResolveEndpoints(BrandLark) + if ep.Open != "https://open.larksuite.com" { + t.Errorf("Open = %q, want larksuite.com", ep.Open) + } + if ep.Accounts != "https://accounts.larksuite.com" { + t.Errorf("Accounts = %q, want larksuite.com", ep.Accounts) + } + if ep.MCP != "https://mcp.larksuite.com" { + t.Errorf("MCP = %q, want larksuite.com", ep.MCP) + } +} + +func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { + ep := ResolveEndpoints("") + if ep.Open != "https://open.feishu.cn" { + t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open) + } +} + +func TestResolveOpenBaseURL(t *testing.T) { + if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { + t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) + } + if got := ResolveOpenBaseURL(BrandLark); got != "https://open.larksuite.com" { + t.Errorf("ResolveOpenBaseURL(lark) = %q", got) + } +} diff --git a/internal/httpmock/registry.go b/internal/httpmock/registry.go new file mode 100644 index 00000000..2bdec941 --- /dev/null +++ b/internal/httpmock/registry.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package httpmock + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "testing" +) + +// Stub defines a preset HTTP response. +type Stub struct { + Method string // empty = match any method + URL string // substring match on URL + Status int // default 200 + Body interface{} // auto JSON-serialized + RawBody []byte // raw bytes (takes precedence over Body when non-nil) + ContentType string // override Content-Type header (default: application/json) + Headers http.Header // optional full response headers (takes precedence over ContentType) + matched bool + + // CapturedHeaders records the request headers of the matched request. + // Populated after RoundTrip matches this stub. + CapturedHeaders http.Header + CapturedBody []byte +} + +// Registry records stubs and implements http.RoundTripper. +type Registry struct { + mu sync.Mutex + stubs []*Stub +} + +// Register adds a stub to the registry. +func (r *Registry) Register(s *Stub) { + r.mu.Lock() + defer r.mu.Unlock() + if s.Status == 0 { + s.Status = 200 + } + r.stubs = append(r.stubs, s) +} + +// RoundTrip implements http.RoundTripper. +func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) { + urlStr := req.URL.String() + + r.mu.Lock() + var matched *Stub + for _, s := range r.stubs { + if s.matched { + continue + } + if s.Method != "" && s.Method != req.Method { + continue + } + if s.URL != "" && !strings.Contains(urlStr, s.URL) { + continue + } + s.matched = true + s.CapturedHeaders = req.Header.Clone() + if req.Body != nil { + s.CapturedBody, _ = io.ReadAll(req.Body) + req.Body = io.NopCloser(bytes.NewReader(s.CapturedBody)) + } + matched = s + break + } + r.mu.Unlock() + + if matched != nil { + resp, err := stubResponse(matched) + if err != nil { + return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err) + } + return resp, nil + } + return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL) +} + +// Verify asserts all stubs were matched. +func (r *Registry) Verify(t testing.TB) { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + for _, s := range r.stubs { + if !s.matched { + t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL) + } + } +} + +func stubResponse(s *Stub) (*http.Response, error) { + ct := s.ContentType + if ct == "" { + ct = "application/json" + } + + var body io.ReadCloser + if s.RawBody != nil { + body = io.NopCloser(bytes.NewReader(s.RawBody)) + } else { + switch v := s.Body.(type) { + case string: + body = io.NopCloser(strings.NewReader(v)) + case []byte: + body = io.NopCloser(bytes.NewReader(v)) + default: + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshal body: %w", err) + } + body = io.NopCloser(bytes.NewReader(b)) + } + } + return &http.Response{ + StatusCode: s.Status, + Header: func() http.Header { + if s.Headers != nil { + return s.Headers.Clone() + } + return http.Header{"Content-Type": []string{ct}} + }(), + Body: body, + }, nil +} + +// NewClient returns an http.Client that uses the Registry as its transport. +func NewClient(reg *Registry) *http.Client { + return &http.Client{Transport: reg} +} diff --git a/internal/httpmock/registry_test.go b/internal/httpmock/registry_test.go new file mode 100644 index 00000000..ed8b9099 --- /dev/null +++ b/internal/httpmock/registry_test.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package httpmock + +import ( + "io" + "net/http" + "testing" +) + +func TestRegistry_RoundTrip(t *testing.T) { + reg := &Registry{} + reg.Register(&Stub{ + Method: "GET", + URL: "/open-apis/test", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + client := NewClient(reg) + req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("want status 200, got %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + if got := string(body); got == "" { + t.Error("expected non-empty body") + } +} + +func TestRegistry_NoStub(t *testing.T) { + reg := &Registry{} + client := NewClient(reg) + req, _ := http.NewRequest("GET", "https://example.com/missing", nil) + _, err := client.Do(req) + if err == nil { + t.Fatal("expected error for unmatched request") + } +} + +func TestRegistry_MethodMismatch(t *testing.T) { + reg := &Registry{} + reg.Register(&Stub{ + Method: "POST", + URL: "/open-apis/test", + Body: "ok", + }) + + client := NewClient(reg) + req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil) + _, err := client.Do(req) + if err == nil { + t.Fatal("expected error for method mismatch") + } +} + +func TestRegistry_Verify_AllMatched(t *testing.T) { + reg := &Registry{} + reg.Register(&Stub{ + Method: "GET", + URL: "/used", + Body: "ok", + }) + + client := NewClient(reg) + req, _ := http.NewRequest("GET", "https://example.com/used", nil) + resp, _ := client.Do(req) + resp.Body.Close() + + reg.Verify(t) +} + +func TestRegistry_Verify_Unmatched(t *testing.T) { + reg := &Registry{} + reg.Register(&Stub{ + Method: "DELETE", + URL: "/unused", + Body: "ok", + }) + + fakeT := &testing.T{} + reg.Verify(fakeT) + if !fakeT.Failed() { + t.Error("Verify should report failure for unmatched stub") + } +} + +func TestRegistry_CustomStatus(t *testing.T) { + reg := &Registry{} + reg.Register(&Stub{ + URL: "/error", + Status: 500, + Body: `{"error":"internal"}`, + }) + + client := NewClient(reg) + req, _ := http.NewRequest("GET", "https://example.com/error", nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 500 { + t.Errorf("want status 500, got %d", resp.StatusCode) + } +} diff --git a/internal/keychain/default.go b/internal/keychain/default.go new file mode 100644 index 00000000..5d9e3d10 --- /dev/null +++ b/internal/keychain/default.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keychain + +import "fmt" + +// defaultKeychain implements KeychainAccess using the real platform keychain. +type defaultKeychain struct{} + +func (d *defaultKeychain) Get(service, account string) (string, error) { + val := Get(service, account) + if val == "" { + return "", fmt.Errorf("keychain entry not found: %s/%s", service, account) + } + return val, nil +} + +func (d *defaultKeychain) Set(service, account, value string) error { + return Set(service, account, value) +} + +func (d *defaultKeychain) Remove(service, account string) error { + return Remove(service, account) +} + +// Default returns a KeychainAccess backed by the real platform keychain. +func Default() KeychainAccess { + return &defaultKeychain{} +} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go new file mode 100644 index 00000000..c225db8b --- /dev/null +++ b/internal/keychain/keychain.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package keychain provides cross-platform secure storage for secrets. +// macOS uses the system Keychain; Linux uses AES-256-GCM encrypted files; Windows uses DPAPI + registry. +package keychain + +const ( + // LarkCliService is the unified keychain service name for all secrets + // (both AppSecret and UAT). Entries are distinguished by account key format: + // - AppSecret: "appsecret:" + // - UAT: ":" + LarkCliService = "lark-cli" +) + +// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection. +// Used by AppSecret operations (ForStorage, ResolveSecretInput, RemoveSecretStore). +// UAT operations in token_store.go use the package-level Get/Set/Remove directly. +type KeychainAccess interface { + Get(service, account string) (string, error) + Set(service, account, value string) error + Remove(service, account string) error +} + +// Get retrieves a value from the keychain. +// Returns empty string if the entry does not exist. +func Get(service, account string) string { + return platformGet(service, account) +} + +// Set stores a value in the keychain, overwriting any existing entry. +func Set(service, account, data string) error { + return platformSet(service, account, data) +} + +// Remove deletes an entry from the keychain. No error if not found. +func Remove(service, account string) error { + return platformRemove(service, account) +} diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go new file mode 100644 index 00000000..fe71583d --- /dev/null +++ b/internal/keychain/keychain_darwin.go @@ -0,0 +1,179 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build darwin + +package keychain + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/google/uuid" + "github.com/zalando/go-keyring" +) + +const keychainTimeout = 5 * time.Second +const masterKeyBytes = 32 +const ivBytes = 12 +const tagBytes = 16 + +// StorageDir returns the storage directory for a given service name on macOS. +func StorageDir(service string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return filepath.Join(".lark-cli", "keychain", service) + } + return filepath.Join(home, "Library", "Application Support", service) +} + +var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func safeFileName(account string) string { + return safeFileNameRe.ReplaceAllString(account, "_") + ".enc" +} + +func getMasterKey(service string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout) + defer cancel() + + type result struct { + key []byte + err error + } + resCh := make(chan result, 1) + go func() { + defer func() { recover() }() + + encodedKey, err := keyring.Get(service, "master.key") + if err == nil { + key, decodeErr := base64.StdEncoding.DecodeString(encodedKey) + if decodeErr == nil && len(key) == masterKeyBytes { + resCh <- result{key: key, err: nil} + return + } + } + + // Generate new master key if not found or invalid + key := make([]byte, masterKeyBytes) + if _, randErr := rand.Read(key); randErr != nil { + resCh <- result{key: nil, err: randErr} + return + } + + encodedKey = base64.StdEncoding.EncodeToString(key) + setErr := keyring.Set(service, "master.key", encodedKey) + resCh <- result{key: key, err: setErr} + }() + + select { + case res := <-resCh: + return res.key, res.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func encryptData(plaintext string, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + iv := make([]byte, ivBytes) + if _, err := rand.Read(iv); err != nil { + return nil, err + } + + ciphertext := aesGCM.Seal(nil, iv, []byte(plaintext), nil) + result := make([]byte, 0, ivBytes+len(ciphertext)) + result = append(result, iv...) + result = append(result, ciphertext...) + return result, nil +} + +func decryptData(data []byte, key []byte) (string, error) { + if len(data) < ivBytes+tagBytes { + return "", os.ErrInvalid + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + iv := data[:ivBytes] + ciphertext := data[ivBytes:] + plaintext, err := aesGCM.Open(nil, iv, ciphertext, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func platformGet(service, account string) string { + key, err := getMasterKey(service) + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account))) + if err != nil { + return "" + } + plaintext, err := decryptData(data, key) + if err != nil { + return "" + } + return plaintext +} + +func platformSet(service, account, data string) error { + key, err := getMasterKey(service) + if err != nil { + return err + } + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + encrypted, err := encryptData(data, key) + if err != nil { + return err + } + + targetPath := filepath.Join(dir, safeFileName(account)) + tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp") + defer os.Remove(tmpPath) + + if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil { + return err + } + + // Atomic rename to prevent file corruption during multi-process writes + if err := os.Rename(tmpPath, targetPath); err != nil { + return err + } + return nil +} + +func platformRemove(service, account string) error { + err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account))) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/internal/keychain/keychain_other.go b/internal/keychain/keychain_other.go new file mode 100644 index 00000000..631a9fb0 --- /dev/null +++ b/internal/keychain/keychain_other.go @@ -0,0 +1,176 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build linux + +package keychain + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/google/uuid" +) + +const masterKeyBytes = 32 +const ivBytes = 12 +const tagBytes = 16 + +// StorageDir returns the storage directory for a given service name. +// Each service gets its own directory for physical isolation. +func StorageDir(service string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + // If home is missing, fallback to relative path and print warning. + // This matches the behavior in internal/core/config.go. + fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) + } + xdgData := filepath.Join(home, ".local", "share") + return filepath.Join(xdgData, service) +} + +var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func safeFileName(account string) string { + return safeFileNameRe.ReplaceAllString(account, "_") + ".enc" +} + +func getMasterKey(service string) ([]byte, error) { + dir := StorageDir(service) + keyPath := filepath.Join(dir, "master.key") + + key, err := os.ReadFile(keyPath) + if err == nil && len(key) == masterKeyBytes { + return key, nil + } + + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + + key = make([]byte, masterKeyBytes) + if _, err := rand.Read(key); err != nil { + return nil, err + } + + tmpKeyPath := filepath.Join(dir, "master.key."+uuid.New().String()+".tmp") + defer os.Remove(tmpKeyPath) + + if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil { + return nil, err + } + + // Atomic rename to prevent multi-process master key initialization collision + if err := os.Rename(tmpKeyPath, keyPath); err != nil { + // If rename fails, another process might have created it. Try reading again. + existingKey, readErr := os.ReadFile(keyPath) + if readErr == nil && len(existingKey) == masterKeyBytes { + return existingKey, nil + } + return nil, err + } + + return key, nil +} + +func encryptData(plaintext string, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + iv := make([]byte, ivBytes) + if _, err := rand.Read(iv); err != nil { + return nil, err + } + + ciphertext := aesGCM.Seal(nil, iv, []byte(plaintext), nil) + result := make([]byte, 0, ivBytes+len(ciphertext)) + result = append(result, iv...) + result = append(result, ciphertext...) + return result, nil +} + +func decryptData(data []byte, key []byte) (string, error) { + if len(data) < ivBytes+tagBytes { + return "", os.ErrInvalid + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + iv := data[:ivBytes] + ciphertext := data[ivBytes:] + plaintext, err := aesGCM.Open(nil, iv, ciphertext, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func platformGet(service, account string) string { + key, err := getMasterKey(service) + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account))) + if err != nil { + return "" + } + plaintext, err := decryptData(data, key) + if err != nil { + return "" + } + return plaintext +} + +func platformSet(service, account, data string) error { + key, err := getMasterKey(service) + if err != nil { + return err + } + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + encrypted, err := encryptData(data, key) + if err != nil { + return err + } + + targetPath := filepath.Join(dir, safeFileName(account)) + tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp") + defer os.Remove(tmpPath) + + if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil { + return err + } + + // Atomic rename to prevent file corruption during multi-process writes + if err := os.Rename(tmpPath, targetPath); err != nil { + return err + } + return nil +} + +func platformRemove(service, account string) error { + err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account))) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/internal/keychain/keychain_windows.go b/internal/keychain/keychain_windows.go new file mode 100644 index 00000000..8830e8ac --- /dev/null +++ b/internal/keychain/keychain_windows.go @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build windows + +package keychain + +import ( + "encoding/base64" + "fmt" + "regexp" + "strings" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// --------------------------------------------------------------------------- +// Windows backend: DPAPI + HKCU registry +// --------------------------------------------------------------------------- + +const regRootPath = `Software\LarkCli\keychain` + +func registryPathForService(service string) string { + return regRootPath + `\` + safeRegistryComponent(service) +} + +var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func safeRegistryComponent(s string) string { + // Registry key path uses '\\' separators; avoid accidental nesting and odd chars. + s = strings.ReplaceAll(s, "\\", "_") + return safeRegRe.ReplaceAllString(s, "_") +} + +func valueNameForAccount(account string) string { + // Avoid any special characters; keep deterministic. + return base64.RawURLEncoding.EncodeToString([]byte(account)) +} + +func dpapiEntropy(service, account string) *windows.DataBlob { + // Bind ciphertext to (service, account) to reduce swap/replay risks. + // Note: empty entropy is allowed, but we intentionally use deterministic entropy. + data := []byte(service + "\x00" + account) + if len(data) == 0 { + return nil + } + return &windows.DataBlob{Size: uint32(len(data)), Data: &data[0]} +} + +func dpapiProtect(plaintext []byte, entropy *windows.DataBlob) ([]byte, error) { + var in windows.DataBlob + if len(plaintext) > 0 { + in = windows.DataBlob{Size: uint32(len(plaintext)), Data: &plaintext[0]} + } + var out windows.DataBlob + err := windows.CryptProtectData(&in, nil, entropy, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) + if err != nil { + return nil, err + } + defer freeDataBlob(&out) + + if out.Data == nil || out.Size == 0 { + return []byte{}, nil + } + buf := unsafe.Slice(out.Data, int(out.Size)) + res := make([]byte, len(buf)) + copy(res, buf) + return res, nil +} + +func dpapiUnprotect(ciphertext []byte, entropy *windows.DataBlob) ([]byte, error) { + var in windows.DataBlob + if len(ciphertext) > 0 { + in = windows.DataBlob{Size: uint32(len(ciphertext)), Data: &ciphertext[0]} + } + var out windows.DataBlob + err := windows.CryptUnprotectData(&in, nil, entropy, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) + if err != nil { + return nil, err + } + defer freeDataBlob(&out) + + if out.Data == nil || out.Size == 0 { + return []byte{}, nil + } + buf := unsafe.Slice(out.Data, int(out.Size)) + res := make([]byte, len(buf)) + copy(res, buf) + return res, nil +} + +func freeDataBlob(b *windows.DataBlob) { + if b == nil || b.Data == nil { + return + } + // Per DPAPI contract, output buffers must be freed with LocalFree. + _, _ = windows.LocalFree(windows.Handle(unsafe.Pointer(b.Data))) + b.Data = nil + b.Size = 0 +} + +func platformGet(service, account string) string { + v, _ := registryGet(service, account) + return v +} + +func platformSet(service, account, data string) error { + entropy := dpapiEntropy(service, account) + protected, err := dpapiProtect([]byte(data), entropy) + if err != nil { + return fmt.Errorf("dpapi protect failed: %w", err) + } + return registrySet(service, account, protected) +} + +func platformRemove(service, account string) error { + return registryRemove(service, account) +} + +func registryGet(service, account string) (string, bool) { + keyPath := registryPathForService(service) + k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) + if err != nil { + return "", false + } + defer k.Close() + + b64, _, err := k.GetStringValue(valueNameForAccount(account)) + if err != nil || b64 == "" { + return "", false + } + blob, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", false + } + entropy := dpapiEntropy(service, account) + plain, err := dpapiUnprotect(blob, entropy) + if err != nil { + return "", false + } + return string(plain), true +} + +func registrySet(service, account string, protected []byte) error { + keyPath := registryPathForService(service) + k, _, err := registry.CreateKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("registry create/open failed: %w", err) + } + defer k.Close() + + b64 := base64.StdEncoding.EncodeToString(protected) + if err := k.SetStringValue(valueNameForAccount(account), b64); err != nil { + return fmt.Errorf("registry set failed: %w", err) + } + return nil +} + +func registryRemove(service, account string) error { + keyPath := registryPathForService(service) + k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE) + if err != nil { + return nil + } + defer k.Close() + _ = k.DeleteValue(valueNameForAccount(account)) + return nil +} diff --git a/internal/lockfile/lock_unix.go b/internal/lockfile/lock_unix.go new file mode 100644 index 00000000..74670f15 --- /dev/null +++ b/internal/lockfile/lock_unix.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package lockfile + +import ( + "fmt" + "os" + "syscall" +) + +func tryLockFile(f *os.File) error { + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + return fmt.Errorf("lock already held by another process (lock: %s): %w", f.Name(), err) + } + return nil +} + +func unlockFile(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/internal/lockfile/lock_windows.go b/internal/lockfile/lock_windows.go new file mode 100644 index 00000000..6daf9744 --- /dev/null +++ b/internal/lockfile/lock_windows.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build windows + +package lockfile + +import ( + "fmt" + "os" + "syscall" + "unsafe" +) + +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + procLockFileEx = modkernel32.NewProc("LockFileEx") + procUnlockFile = modkernel32.NewProc("UnlockFileEx") +) + +const ( + lockfileExclusiveLock = 0x00000002 + lockfileFailImmediately = 0x00000001 +) + +func tryLockFile(f *os.File) error { + // OVERLAPPED structure (zeroed) + var ol syscall.Overlapped + handle := syscall.Handle(f.Fd()) + // LockFileEx(handle, flags, reserved, nNumberOfBytesToLockLow, nNumberOfBytesToLockHigh, *overlapped) + r1, _, err := procLockFileEx.Call( + uintptr(handle), + uintptr(lockfileExclusiveLock|lockfileFailImmediately), + 0, + 1, 0, + uintptr(unsafe.Pointer(&ol)), + ) + if r1 == 0 { + return fmt.Errorf("lock already held by another process (lock: %s): %v", f.Name(), err) + } + return nil +} + +func unlockFile(f *os.File) error { + var ol syscall.Overlapped + handle := syscall.Handle(f.Fd()) + r1, _, err := procUnlockFile.Call( + uintptr(handle), + 0, + 1, 0, + uintptr(unsafe.Pointer(&ol)), + ) + if r1 == 0 { + return err + } + return nil +} diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go new file mode 100644 index 00000000..96563b9b --- /dev/null +++ b/internal/lockfile/lockfile.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lockfile + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/larksuite/cli/internal/core" +) + +// safeIDChars strips everything except alphanumerics, underscores, hyphens, and dots +// to prevent path traversal via crafted app IDs (e.g. "../../tmp/evil"). +var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +// LockFile represents an exclusive file lock. +type LockFile struct { + path string + file *os.File +} + +// New creates a LockFile for the given path (does not acquire the lock). +func New(path string) *LockFile { + return &LockFile{path: path} +} + +// ForSubscribe returns a LockFile scoped to the event subscribe command for a given App ID. +// Lock path: {configDir}/locks/subscribe_{appID}.lock +// +// The appID is sanitized to prevent path traversal: any character outside +// [a-zA-Z0-9._-] is replaced with "_", and filepath.Base strips directory +// components, so a malicious appID like "../../tmp/evil" becomes a flat +// filename under the locks directory. +func ForSubscribe(appID string) (*LockFile, error) { + if appID == "" { + return nil, fmt.Errorf("app ID must not be empty") + } + dir := filepath.Join(core.GetConfigDir(), "locks") + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("create lock dir: %w", err) + } + safe := safeIDChars.ReplaceAllString(appID, "_") + name := filepath.Base(fmt.Sprintf("subscribe_%s.lock", safe)) + path := filepath.Join(dir, name) + return New(path), nil +} + +// TryLock attempts to acquire an exclusive, non-blocking lock. +// Returns nil on success. Returns an error if the lock is already held +// by another process (or on any other failure). +// The lock is automatically released when the process exits. +func (l *LockFile) TryLock() error { + if l.file != nil { + return fmt.Errorf("lock already held: %s", l.path) + } + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return fmt.Errorf("open lock file: %w", err) + } + if err := tryLockFile(f); err != nil { + f.Close() + return err + } + l.file = f + return nil +} + +// Unlock releases the lock and closes the file descriptor. +// The lock file is intentionally kept on disk to avoid an inode-reuse race: +// removing the path between unlock and a competing open+flock would let two +// processes lock different inodes under the same name. +func (l *LockFile) Unlock() error { + if l.file == nil { + return nil + } + err := unlockFile(l.file) + closeErr := l.file.Close() + l.file = nil + if err != nil { + return fmt.Errorf("unlock file: %w", err) + } + return closeErr +} + +// Path returns the lock file path. +func (l *LockFile) Path() string { + return l.path +} diff --git a/internal/lockfile/lockfile_test.go b/internal/lockfile/lockfile_test.go new file mode 100644 index 00000000..f6d50658 --- /dev/null +++ b/internal/lockfile/lockfile_test.go @@ -0,0 +1,197 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lockfile + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func newTestLock(t *testing.T) *LockFile { + t.Helper() + return New(filepath.Join(t.TempDir(), "test.lock")) +} + +func TestTryLock_Success(t *testing.T) { + l := newTestLock(t) + + if err := l.TryLock(); err != nil { + t.Fatalf("TryLock failed: %v", err) + } + defer l.Unlock() + + if _, err := os.Stat(l.Path()); os.IsNotExist(err) { + t.Error("lock file should exist after TryLock") + } +} + +func TestTryLock_Conflict(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.lock") + + l1 := New(path) + if err := l1.TryLock(); err != nil { + t.Fatalf("first TryLock failed: %v", err) + } + defer l1.Unlock() + + l2 := New(path) + if err := l2.TryLock(); err == nil { + l2.Unlock() + t.Fatal("second TryLock should fail when lock is held by another instance") + } +} + +func TestTryLock_AlreadyHeld(t *testing.T) { + l := newTestLock(t) + + if err := l.TryLock(); err != nil { + t.Fatalf("TryLock failed: %v", err) + } + defer l.Unlock() + + err := l.TryLock() + if err == nil { + t.Fatal("double TryLock on same instance should fail") + } + if !strings.Contains(err.Error(), "lock already held") { + t.Errorf("error should mention 'lock already held', got: %v", err) + } +} + +func TestTryLock_InvalidPath(t *testing.T) { + l := New(filepath.Join(t.TempDir(), "no-such-dir", "test.lock")) + + err := l.TryLock() + if err == nil { + l.Unlock() + t.Fatal("TryLock should fail for non-existent parent directory") + } + if !strings.Contains(err.Error(), "open lock file") { + t.Errorf("error should mention 'open lock file', got: %v", err) + } +} + +func TestUnlock_ReleasesLock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.lock") + + l1 := New(path) + if err := l1.TryLock(); err != nil { + t.Fatalf("TryLock failed: %v", err) + } + if err := l1.Unlock(); err != nil { + t.Fatalf("Unlock failed: %v", err) + } + + l2 := New(path) + if err := l2.TryLock(); err != nil { + t.Fatalf("TryLock after Unlock should succeed: %v", err) + } + defer l2.Unlock() +} + +func TestUnlock_KeepsFileOnDisk(t *testing.T) { + l := newTestLock(t) + + if err := l.TryLock(); err != nil { + t.Fatalf("TryLock failed: %v", err) + } + path := l.Path() + if err := l.Unlock(); err != nil { + t.Fatalf("Unlock failed: %v", err) + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Error("lock file should remain on disk after Unlock") + } +} + +func TestUnlock_Idempotent(t *testing.T) { + l := newTestLock(t) + + // Unlock without prior lock + if err := l.Unlock(); err != nil { + t.Fatalf("Unlock without lock should not error: %v", err) + } + + // Lock then double unlock + if err := l.TryLock(); err != nil { + t.Fatalf("TryLock failed: %v", err) + } + if err := l.Unlock(); err != nil { + t.Fatalf("first Unlock failed: %v", err) + } + if err := l.Unlock(); err != nil { + t.Fatalf("second Unlock should not error: %v", err) + } +} + +func TestPath(t *testing.T) { + l := New("/tmp/test.lock") + if l.Path() != "/tmp/test.lock" { + t.Errorf("Path() = %q, want /tmp/test.lock", l.Path()) + } +} + +func TestForSubscribe(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + l, err := ForSubscribe("cli_test123") + if err != nil { + t.Fatalf("ForSubscribe failed: %v", err) + } + + expected := filepath.Join(dir, "locks", "subscribe_cli_test123.lock") + if l.Path() != expected { + t.Errorf("Path() = %q, want %q", l.Path(), expected) + } + + lockDir := filepath.Join(dir, "locks") + if _, err := os.Stat(lockDir); os.IsNotExist(err) { + t.Error("locks directory should be created by ForSubscribe") + } +} + +func TestForSubscribe_SanitizesAppID(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + for _, tt := range []struct { + name string + appID string + wantBase string + }{ + {"path traversal", "../../tmp/evil", "subscribe_.._.._tmp_evil.lock"}, + {"slashes", "cli/app/id", "subscribe_cli_app_id.lock"}, + {"normal id", "cli_a1b2c3", "subscribe_cli_a1b2c3.lock"}, + {"special chars", "app@id:123", "subscribe_app_id_123.lock"}, + } { + t.Run(tt.name, func(t *testing.T) { + l, err := ForSubscribe(tt.appID) + if err != nil { + t.Fatalf("ForSubscribe(%q) failed: %v", tt.appID, err) + } + gotBase := filepath.Base(l.Path()) + if gotBase != tt.wantBase { + t.Errorf("Base(Path()) = %q, want %q", gotBase, tt.wantBase) + } + // Lock file must always be under the locks directory + locksDir := filepath.Join(dir, "locks") + if !strings.HasPrefix(l.Path(), locksDir) { + t.Errorf("path %q escapes locks dir %q", l.Path(), locksDir) + } + }) + } +} + +func TestForSubscribe_RejectsEmptyAppID(t *testing.T) { + _, err := ForSubscribe("") + if err == nil { + t.Fatal("ForSubscribe should reject empty app ID") + } +} diff --git a/internal/output/colors.go b/internal/output/colors.go new file mode 100644 index 00000000..6a74f311 --- /dev/null +++ b/internal/output/colors.go @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +const ( + Dim = "\033[2m" + Bold = "\033[1m" + Yellow = "\033[33m" + Cyan = "\033[36m" + Red = "\033[31m" + Green = "\033[32m" + Reset = "\033[0m" +) diff --git a/internal/output/csv.go b/internal/output/csv.go new file mode 100644 index 00000000..22be2b26 --- /dev/null +++ b/internal/output/csv.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "encoding/csv" + "fmt" + "io" + "os" +) + +// FormatAsCSV formats data as CSV (with header) and writes it to w. +func FormatAsCSV(w io.Writer, data interface{}) { + FormatAsCSVPaginated(w, data, true) +} + +// FormatAsCSVPaginated formats data as CSV with pagination awareness. +// When isFirstPage is true, outputs the header row; otherwise only data rows. +func FormatAsCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) { + rows, cols, isList := prepareRows(data) + if cols == nil { + if isList { + fmt.Fprintln(w, "(empty)") + } else { + PrintJson(w, data) + } + return + } + + if len(rows) == 0 { + if isFirstPage { + fmt.Fprintln(w, "(empty)") + } + return + } + + if !isList { + // Single object: key,value rows + cw := csv.NewWriter(w) + if isFirstPage { + cw.Write([]string{"key", "value"}) + } + for _, col := range cols { + cw.Write([]string{col, rows[0][col]}) + } + flushCSV(cw) + return + } + + writeCSVRows(w, rows, cols, isFirstPage) +} + +// writeCSVRows writes CSV data rows (and optionally header) using the given columns. +func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) { + cw := csv.NewWriter(w) + if writeHeader { + cw.Write(cols) + } + for _, row := range rows { + record := make([]string, len(cols)) + for i, col := range cols { + record[i] = row[col] + } + cw.Write(record) + } + flushCSV(cw) +} + +// flushCSV flushes the csv.Writer and reports any write error to stderr. +func flushCSV(cw *csv.Writer) { + cw.Flush() + if err := cw.Error(); err != nil { + fmt.Fprintf(os.Stderr, "csv write error: %v\n", err) + } +} diff --git a/internal/output/csv_test.go b/internal/output/csv_test.go new file mode 100644 index 00000000..d22248a4 --- /dev/null +++ b/internal/output/csv_test.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestFormatAsCSV_BasicArray(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Alice", "age": float64(30)}, + map[string]interface{}{"name": "Bob", "age": float64(25)}, + } + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := buf.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + if len(lines) != 3 { + t.Fatalf("expected 3 lines (header + 2 rows), got %d:\n%s", len(lines), out) + } + + // Header should contain both column names + header := lines[0] + if !strings.Contains(header, "name") || !strings.Contains(header, "age") { + t.Errorf("header should contain 'name' and 'age', got: %s", header) + } +} + +func TestFormatAsCSV_RFC4180Escaping(t *testing.T) { + data := []interface{}{ + map[string]interface{}{ + "text": `hello, "world"`, + }, + } + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := buf.String() + + // RFC 4180: fields with commas/quotes are quoted, internal quotes are doubled + if !strings.Contains(out, `"hello, ""world"""`) { + t.Errorf("CSV should properly escape commas and quotes, got:\n%s", out) + } +} + +func TestFormatAsCSV_NewlineInValue(t *testing.T) { + data := []interface{}{ + map[string]interface{}{ + "text": "line1\nline2", + }, + } + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := buf.String() + + // RFC 4180: fields with newlines should be quoted + if !strings.Contains(out, `"line1`) { + t.Errorf("CSV should quote fields containing newlines, got:\n%s", out) + } +} + +func TestFormatAsCSV_NestedObject(t *testing.T) { + data := []interface{}{ + map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Alice", + }, + "id": float64(1), + }, + } + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := buf.String() + + if !strings.Contains(out, "user.name") { + t.Errorf("CSV should contain flattened 'user.name' column, got:\n%s", out) + } +} + +func TestFormatAsCSV_EmptyArray(t *testing.T) { + data := []interface{}{} + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := strings.TrimSpace(buf.String()) + + if out != "(empty)" { + t.Errorf("empty array should output '(empty)', got:\n%s", out) + } +} + +func TestFormatAsCSVPaginated_FirstPage(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Alice"}, + } + + var buf bytes.Buffer + FormatAsCSVPaginated(&buf, data, true) + out := buf.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + if len(lines) != 2 { + t.Errorf("first page should have header + 1 data row, got %d lines:\n%s", len(lines), out) + } + if lines[0] != "name" { + t.Errorf("first line should be header 'name', got: %s", lines[0]) + } +} + +func TestFormatAsCSVPaginated_ContinuationPage(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Bob"}, + } + + var buf bytes.Buffer + FormatAsCSVPaginated(&buf, data, false) + out := buf.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + if len(lines) != 1 { + t.Errorf("continuation page should have 1 data row, got %d lines:\n%s", len(lines), out) + } + if lines[0] != "Bob" { + t.Errorf("continuation page data should be 'Bob', got: %s", lines[0]) + } +} + +func TestFormatAsCSV_SingleObject(t *testing.T) { + data := map[string]interface{}{ + "name": "Alice", + "age": float64(30), + } + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := buf.String() + + // Single object should render as key,value format + if !strings.Contains(out, "key,value") { + t.Errorf("single object should have key,value header, got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("output should contain 'Alice', got:\n%s", out) + } +} diff --git a/internal/output/envelope.go b/internal/output/envelope.go new file mode 100644 index 00000000..21caefab --- /dev/null +++ b/internal/output/envelope.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +// Envelope is the standard success response wrapper. +type Envelope struct { + OK bool `json:"ok"` + Identity string `json:"identity,omitempty"` + Data interface{} `json:"data,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +// ErrorEnvelope is the standard error response wrapper. +type ErrorEnvelope struct { + OK bool `json:"ok"` + Identity string `json:"identity,omitempty"` + Error *ErrDetail `json:"error"` + Meta *Meta `json:"meta,omitempty"` +} + +// ErrDetail describes a structured error. +type ErrDetail struct { + Type string `json:"type"` + Code int `json:"code,omitempty"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + ConsoleURL string `json:"console_url,omitempty"` + Detail interface{} `json:"detail,omitempty"` +} + +// Meta carries optional metadata in envelope responses. +type Meta struct { + Count int `json:"count,omitempty"` + Rollback string `json:"rollback,omitempty"` +} diff --git a/internal/output/errors.go b/internal/output/errors.go new file mode 100644 index 00000000..e61c9b27 --- /dev/null +++ b/internal/output/errors.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" +) + +// ExitError is a structured error that carries an exit code and optional detail. +// It is propagated up the call chain and handled by main.go to produce +// a JSON error envelope on stderr and the correct exit code. +type ExitError struct { + Code int + Detail *ErrDetail + Err error + Raw bool // when true, skip enrichment (e.g. enrichPermissionError) and preserve original error +} + +func (e *ExitError) Error() string { + if e.Detail != nil { + return e.Detail.Message + } + if e.Err != nil { + return e.Err.Error() + } + return fmt.Sprintf("exit %d", e.Code) +} + +func (e *ExitError) Unwrap() error { + return e.Err +} + +// WriteErrorEnvelope writes a JSON error envelope for the given ExitError to w. +func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) { + if err.Detail == nil { + return + } + env := ErrorEnvelope{ + OK: false, + Identity: identity, + Error: err.Detail, + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(env); err != nil { + return + } + // Encode appends a trailing newline; write directly. + buf.WriteTo(w) +} + +// --- Convenience constructors --- + +// Errorf creates an ExitError with the given code, type, and formatted message. +func Errorf(code int, errType, format string, args ...any) *ExitError { + var err error + for _, arg := range args { + if e, ok := arg.(error); ok { + err = e + break + } + } + return &ExitError{ + Code: code, + Detail: &ErrDetail{Type: errType, Message: fmt.Sprintf(format, args...)}, + Err: err, + } +} + +// ErrValidation creates a validation ExitError (exit 2). +func ErrValidation(format string, args ...any) *ExitError { + return Errorf(ExitValidation, "validation", format, args...) +} + +// ErrAuth creates an auth ExitError (exit 3). +func ErrAuth(format string, args ...any) *ExitError { + return Errorf(ExitAuth, "auth", format, args...) +} + +// ErrNetwork creates a network ExitError (exit 4). +func ErrNetwork(format string, args ...any) *ExitError { + return Errorf(ExitNetwork, "network", format, args...) +} + +// ErrAPI creates an API ExitError using ClassifyLarkError. +// For permission errors, uses a concise message; the raw API response is preserved in Detail. +func ErrAPI(larkCode int, msg string, detail any) *ExitError { + exitCode, errType, hint := ClassifyLarkError(larkCode, msg) + if errType == "permission" { + msg = fmt.Sprintf("Permission denied [%d]", larkCode) + } + return &ExitError{ + Code: exitCode, + Detail: &ErrDetail{ + Type: errType, + Code: larkCode, + Message: msg, + Hint: hint, + Detail: detail, + }, + } +} + +// ErrWithHint creates an ExitError with a hint string. +func ErrWithHint(code int, errType, msg, hint string) *ExitError { + return &ExitError{ + Code: code, + Detail: &ErrDetail{Type: errType, Message: msg, Hint: hint}, + } +} + +// ErrBare creates an ExitError with only an exit code and no envelope. +// Used for cases like `auth check` where the JSON output is already written to stdout. +func ErrBare(code int) *ExitError { + return &ExitError{Code: code} +} + +// MarkRaw sets Raw=true on an ExitError so that enrichment (e.g. enrichPermissionError) +// is skipped and the original API error is preserved. Returns the original error unchanged +// if it is not an ExitError. +func MarkRaw(err error) error { + var exitErr *ExitError + if errors.As(err, &exitErr) { + exitErr.Raw = true + } + return err +} diff --git a/internal/output/errors_test.go b/internal/output/errors_test.go new file mode 100644 index 00000000..2cc3d1f2 --- /dev/null +++ b/internal/output/errors_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "fmt" + "testing" +) + +func TestMarkRaw_ExitError(t *testing.T) { + err := ErrAPI(99991672, "API error: [99991672] scope not enabled", nil) + if err.Raw { + t.Fatal("expected Raw=false before MarkRaw") + } + + result := MarkRaw(err) + if result != err { + t.Error("expected MarkRaw to return the same error") + } + if !err.Raw { + t.Error("expected Raw=true after MarkRaw") + } +} + +func TestMarkRaw_NonExitError(t *testing.T) { + plain := fmt.Errorf("some plain error") + result := MarkRaw(plain) + if result != plain { + t.Error("expected MarkRaw to return the same error for non-ExitError") + } +} + +func TestMarkRaw_Nil(t *testing.T) { + result := MarkRaw(nil) + if result != nil { + t.Error("expected MarkRaw(nil) to return nil") + } +} diff --git a/internal/output/exitcode.go b/internal/output/exitcode.go new file mode 100644 index 00000000..47628afd --- /dev/null +++ b/internal/output/exitcode.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +// Fine-grained error types (permission, not_found, rate_limit, etc.) +// are communicated via the JSON error envelope's "type" field, +// not via exit codes. +const ( + ExitOK = 0 // 成功 + ExitAPI = 1 // API / 通用错误(含 permission、not_found、conflict、rate_limit) + ExitValidation = 2 // 参数校验失败 + ExitAuth = 3 // 认证失败(token 无效 / 过期) + ExitNetwork = 4 // 网络错误(连接超时、DNS 解析失败等) + ExitInternal = 5 // 内部错误(不应发生) +) diff --git a/internal/output/flatten.go b/internal/output/flatten.go new file mode 100644 index 00000000..594de64e --- /dev/null +++ b/internal/output/flatten.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "sort" + "unicode/utf8" +) + +const maxFlattenDepth = 3 + +type flatEntry struct { + Key string + Value string +} + +// flattenObject flattens a nested object into dot-notation key-value pairs. +// Objects nested beyond maxFlattenDepth levels are serialized as JSON strings. +// Keys are sorted alphabetically for deterministic column order. +func flattenObject(obj map[string]interface{}, prefix string, depth int) []flatEntry { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + + var entries []flatEntry + for _, k := range keys { + v := obj[k] + key := k + if prefix != "" { + key = prefix + "." + k + } + switch val := v.(type) { + case map[string]interface{}: + if depth+1 >= maxFlattenDepth { + entries = append(entries, flatEntry{Key: key, Value: cellStr(val)}) + } else { + entries = append(entries, flattenObject(val, key, depth+1)...) + } + default: + entries = append(entries, flatEntry{Key: key, Value: cellStr(v)}) + } + } + return entries +} + +// collectColumns collects column names from all rows (union set), +// preserving first-occurrence order. +func collectColumns(rows [][]flatEntry) []string { + seen := map[string]bool{} + var cols []string + for _, row := range rows { + for _, e := range row { + if !seen[e.Key] { + seen[e.Key] = true + cols = append(cols, e.Key) + } + } + } + return cols +} + +// rowMap converts a slice of flatEntry into a map for column lookup. +func rowMap(entries []flatEntry) map[string]string { + m := make(map[string]string, len(entries)) + for _, e := range entries { + m[e.Key] = e.Value + } + return m +} + +// runeWidth returns the display width of a rune. +// CJK characters and some symbols are double-width. +func runeWidth(r rune) int { + if r == utf8.RuneError { + return 1 + } + // CJK Unified Ideographs, CJK Compatibility Ideographs, etc. + if (r >= 0x1100 && r <= 0x115F) || // Hangul Jamo + r == 0x2329 || r == 0x232A || + (r >= 0x2E80 && r <= 0x303E) || // CJK Radicals, Kangxi, CJK Symbols + (r >= 0x3040 && r <= 0x33BF) || // Hiragana, Katakana, Bopomofo, etc. + (r >= 0x3400 && r <= 0x4DBF) || // CJK Unified Ideographs Extension A + (r >= 0x4E00 && r <= 0xA4CF) || // CJK Unified Ideographs, Yi + (r >= 0xA960 && r <= 0xA97C) || // Hangul Jamo Extended-A + (r >= 0xAC00 && r <= 0xD7A3) || // Hangul Syllables + (r >= 0xF900 && r <= 0xFAFF) || // CJK Compatibility Ideographs + (r >= 0xFE10 && r <= 0xFE6F) || // CJK Compatibility Forms, Small Forms + (r >= 0xFF01 && r <= 0xFF60) || // Fullwidth Forms + (r >= 0xFFE0 && r <= 0xFFE6) || // Fullwidth Signs + (r >= 0x1F300 && r <= 0x1F9FF) || // Emoji (Miscellaneous Symbols and Pictographs, Emoticons, etc.) + (r >= 0x20000 && r <= 0x2FFFF) || // CJK Unified Ideographs Extension B-F + (r >= 0x30000 && r <= 0x3FFFF) { // CJK Unified Ideographs Extension G+ + return 2 + } + return 1 +} + +// stringWidth returns the display width of a string. +func stringWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +// truncateToWidth truncates a string to fit within maxWidth display columns. +// If truncated, appends "…". +func truncateToWidth(s string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + w := 0 + for i, r := range s { + rw := runeWidth(r) + if w+rw > maxWidth { + return s[:i] + "…" + } + w += rw + } + return s +} + +// flattenItem flattens a single item (object or other) into flatEntry pairs. +func flattenItem(item interface{}) []flatEntry { + if obj, ok := item.(map[string]interface{}); ok { + return flattenObject(obj, "", 0) + } + return []flatEntry{{Key: "value", Value: cellStr(item)}} +} + +// prepareRows converts a data value into flattened rows and column names. +// Returns rows (as maps), columns, and whether the data was a list. +func prepareRows(data interface{}) (rows []map[string]string, cols []string, isList bool) { + items := extractArray(data) + if items == nil { + // Single object + if obj, ok := data.(map[string]interface{}); ok { + entries := flattenObject(obj, "", 0) + rm := rowMap(entries) + flatRows := [][]flatEntry{entries} + return []map[string]string{rm}, collectColumns(flatRows), false + } + return nil, nil, false + } + + isList = true + var flatRows [][]flatEntry + for _, item := range items { + entries := flattenItem(item) + flatRows = append(flatRows, entries) + rows = append(rows, rowMap(entries)) + } + cols = collectColumns(flatRows) + return rows, cols, isList +} + +// extractArray extracts an array from data, or returns nil. +func extractArray(data interface{}) []interface{} { + if arr, ok := data.([]interface{}); ok { + return arr + } + return nil +} diff --git a/internal/output/flatten_test.go b/internal/output/flatten_test.go new file mode 100644 index 00000000..46ef95b3 --- /dev/null +++ b/internal/output/flatten_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "testing" +) + +func TestFlattenObjectSimple(t *testing.T) { + obj := map[string]interface{}{ + "name": "Alice", + "age": float64(30), + } + entries := flattenObject(obj, "", 0) + m := rowMap(entries) + + if m["name"] != "Alice" { + t.Errorf("name = %q, want %q", m["name"], "Alice") + } + if m["age"] != "30" { + t.Errorf("age = %q, want %q", m["age"], "30") + } +} + +func TestFlattenObjectNested(t *testing.T) { + obj := map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Alice", + "addr": map[string]interface{}{ + "city": "Beijing", + }, + }, + } + entries := flattenObject(obj, "", 0) + m := rowMap(entries) + + if m["user.name"] != "Alice" { + t.Errorf("user.name = %q, want %q", m["user.name"], "Alice") + } + if m["user.addr.city"] != "Beijing" { + t.Errorf("user.addr.city = %q, want %q", m["user.addr.city"], "Beijing") + } +} + +func TestFlattenObjectDeepLimit(t *testing.T) { + // Create depth=4 nesting — should serialize the innermost object as JSON + obj := map[string]interface{}{ + "a": map[string]interface{}{ + "b": map[string]interface{}{ + "c": map[string]interface{}{ + "d": "deep", + }, + }, + }, + } + entries := flattenObject(obj, "", 0) + m := rowMap(entries) + + // depth 0 → a (map), depth 1 → b (map), depth 2 → c (map), depth 3 ≥ maxFlattenDepth → serialize + if v, ok := m["a.b.c"]; !ok { + t.Errorf("expected key a.b.c, got keys: %v", m) + } else if v != `{"d":"deep"}` { + t.Errorf("a.b.c = %q, want JSON string", v) + } +} + +func TestFlattenObjectArrayLeaf(t *testing.T) { + obj := map[string]interface{}{ + "tags": []interface{}{"a", "b"}, + } + entries := flattenObject(obj, "", 0) + m := rowMap(entries) + + if m["tags"] != `["a","b"]` { + t.Errorf("tags = %q, want %q", m["tags"], `["a","b"]`) + } +} + +func TestFlattenObjectNilValue(t *testing.T) { + obj := map[string]interface{}{ + "empty": nil, + } + entries := flattenObject(obj, "", 0) + m := rowMap(entries) + + if m["empty"] != "" { + t.Errorf("empty = %q, want %q", m["empty"], "") + } +} + +func TestCollectColumns(t *testing.T) { + rows := [][]flatEntry{ + {{Key: "a", Value: "1"}, {Key: "b", Value: "2"}}, + {{Key: "b", Value: "3"}, {Key: "c", Value: "4"}}, + } + cols := collectColumns(rows) + + // Should contain a, b, c (union) + colSet := map[string]bool{} + for _, c := range cols { + colSet[c] = true + } + for _, expected := range []string{"a", "b", "c"} { + if !colSet[expected] { + t.Errorf("missing column %q in %v", expected, cols) + } + } + if len(cols) != 3 { + t.Errorf("got %d columns, want 3", len(cols)) + } +} + +func TestTruncateToWidth(t *testing.T) { + tests := []struct { + input string + maxWidth int + want string + }{ + {"hello", 10, "hello"}, + {"hello", 5, "hello"}, + {"hello", 4, "hell…"}, + {"hello", 3, "hel…"}, + {"hello", 1, "h…"}, + {"hello", 0, ""}, + // CJK: each char is width 2 + {"你好世界", 8, "你好世界"}, + {"你好世界", 6, "你好世…"}, + {"你好世界", 4, "你好…"}, + {"你好世界", 3, "你…"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := truncateToWidth(tt.input, tt.maxWidth) + if got != tt.want { + t.Errorf("truncateToWidth(%q, %d) = %q, want %q", tt.input, tt.maxWidth, got, tt.want) + } + }) + } +} + +func TestStringWidth(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"hello", 5}, + {"你好", 4}, + {"ab你好cd", 8}, + {"", 0}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := stringWidth(tt.input) + if got != tt.want { + t.Errorf("stringWidth(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/output/format.go b/internal/output/format.go new file mode 100644 index 00000000..a05a2ea1 --- /dev/null +++ b/internal/output/format.go @@ -0,0 +1,195 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "sort" +) + +// Known array field names for pagination. +var knownArrayFields = []string{ + "items", "files", "events", "rooms", "records", "nodes", + "members", "departments", "calendar_list", "acl_list", "freebusy_list", +} + +// FindArrayField finds the primary array field in a response's data object. +// It first checks knownArrayFields in priority order, then falls back to +// the lexicographically smallest unknown array field for deterministic results. +func FindArrayField(data map[string]interface{}) string { + for _, name := range knownArrayFields { + if arr, ok := data[name]; ok { + if _, isArr := arr.([]interface{}); isArr { + return name + } + } + } + // Fallback: lexicographically first array field (deterministic) + var candidates []string + for k, v := range data { + if _, isArr := v.([]interface{}); isArr { + candidates = append(candidates, k) + } + } + if len(candidates) > 0 { + sort.Strings(candidates) + return candidates[0] + } + return "" +} + +// toGeneric normalises any Go value (structs, typed slices, …) into +// plain map[string]interface{} / []interface{} via a JSON round-trip so +// that subsequent type assertions in format handlers work uniformly. +func toGeneric(v interface{}) interface{} { + switch v.(type) { + case map[string]interface{}, []interface{}, nil: + return v // already generic + } + b, err := json.Marshal(v) + if err != nil { + return v + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() // preserve int64 precision (avoid float64 truncation) + var out interface{} + if err := dec.Decode(&out); err != nil { + return v + } + return out +} + +// ExtractItems extracts the data array from a response. +// It tries two strategies in order: +// 1. Lark API envelope: result["data"][arrayField] (e.g. {"code":0,"data":{"items":[…]}}) +// 2. Direct map: result[arrayField] (e.g. {"members":[…],"total":5}) +// +// If data is already a plain []interface{}, it is returned as-is. +func ExtractItems(data interface{}) []interface{} { + resultMap, ok := data.(map[string]interface{}) + if !ok { + if arr, ok := data.([]interface{}); ok { + return arr + } + return nil + } + + // Strategy 1: Lark API envelope — result["data"][arrayField] + if dataObj, ok := resultMap["data"].(map[string]interface{}); ok { + if field := FindArrayField(dataObj); field != "" { + if items, ok := dataObj[field].([]interface{}); ok { + return items + } + } + } + + // Strategy 2: direct map — result[arrayField] + // Covers shortcut-level data like {"members":[…], "total":5, "has_more":false} + if field := FindArrayField(resultMap); field != "" { + if items, ok := resultMap[field].([]interface{}); ok { + return items + } + } + + return nil +} + +// FormatValue formats a single response and writes it to w. +func FormatValue(w io.Writer, data interface{}, format Format) { + data = toGeneric(data) + switch format { + case FormatNDJSON: + items := ExtractItems(data) + if items != nil { + PrintNdjson(w, items) + } else { + PrintNdjson(w, data) + } + + case FormatTable: + items := ExtractItems(data) + if items != nil { + FormatAsTable(w, items) + } else { + FormatAsTable(w, data) + } + + case FormatCSV: + items := ExtractItems(data) + if items != nil { + FormatAsCSV(w, items) + } else { + FormatAsCSV(w, data) + } + + default: // FormatJSON + PrintJson(w, data) + } +} + +// PaginatedFormatter holds state across paginated calls to ensure +// consistent columns (table/csv use the first page's columns for all pages). +type PaginatedFormatter struct { + W io.Writer + Format Format + isFirstPage bool + cols []string // locked after first page +} + +// NewPaginatedFormatter creates a formatter that tracks pagination state. +func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter { + return &PaginatedFormatter{W: w, Format: format, isFirstPage: true} +} + +// FormatPage formats one page of items. +func (pf *PaginatedFormatter) FormatPage(data interface{}) { + switch pf.Format { + case FormatJSON, FormatNDJSON: + if arr, ok := data.([]interface{}); ok { + PrintNdjson(pf.W, arr) + } else { + PrintNdjson(pf.W, data) + } + + case FormatTable: + pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) { + widths := computeColumnWidths(rows, cols) + if isFirst { + writeHeader(w, cols, widths) + } + for _, row := range rows { + writeRow(w, row, cols, widths) + } + }) + + case FormatCSV: + pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) { + writeCSVRows(w, rows, cols, isFirst) + }) + } +} + +// formatStructuredPage handles column-locking logic shared by table and csv. +func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) { + rows, pageCols, isList := prepareRows(data) + if len(rows) == 0 { + if pf.isFirstPage && isList { + fmt.Fprintln(pf.W, "(empty)") + } + return + } + + if pf.isFirstPage { + // Lock columns from first page + pf.cols = pageCols + pf.isFirstPage = false + emit(pf.W, rows, pf.cols, true) + } else { + // Reuse first page's columns — missing keys become empty, extra keys ignored + emit(pf.W, rows, pf.cols, false) + } +} diff --git a/internal/output/format_test.go b/internal/output/format_test.go new file mode 100644 index 00000000..f8603456 --- /dev/null +++ b/internal/output/format_test.go @@ -0,0 +1,301 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestFormatValue_JSON(t *testing.T) { + data := map[string]interface{}{"name": "Alice"} + + var buf bytes.Buffer + FormatValue(&buf, data, FormatJSON) + out := buf.String() + + // Should be pretty-printed JSON + if !strings.Contains(out, `"name"`) { + t.Errorf("JSON output should contain field name, got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("JSON output should contain value, got:\n%s", out) + } +} + +func TestFormatValue_NDJSON(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": float64(1)}, + map[string]interface{}{"id": float64(2)}, + }, + }, + } + + var buf bytes.Buffer + FormatValue(&buf, data, FormatNDJSON) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + + if len(lines) != 2 { + t.Fatalf("NDJSON should output 2 lines, got %d:\n%s", len(lines), buf.String()) + } + + for _, line := range lines { + var obj map[string]interface{} + if err := json.Unmarshal([]byte(line), &obj); err != nil { + t.Errorf("each NDJSON line should be valid JSON: %s", line) + } + } +} + +func TestFormatValue_Table(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + }, + }, + } + + var buf bytes.Buffer + FormatValue(&buf, data, FormatTable) + out := buf.String() + + if !strings.Contains(out, "name") { + t.Errorf("table output should contain 'name' header, got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("table output should contain 'Alice', got:\n%s", out) + } +} + +func TestFormatValue_CSV(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + }, + }, + } + + var buf bytes.Buffer + FormatValue(&buf, data, FormatCSV) + out := buf.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + if len(lines) != 2 { + t.Fatalf("CSV should have header + 1 row, got %d lines:\n%s", len(lines), out) + } + if lines[0] != "name" { + t.Errorf("CSV header should be 'name', got: %s", lines[0]) + } + if lines[1] != "Alice" { + t.Errorf("CSV row should be 'Alice', got: %s", lines[1]) + } +} + +func TestPaginatedFormatter_JSON(t *testing.T) { + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatJSON) + + pf.FormatPage([]interface{}{ + map[string]interface{}{"id": float64(1)}, + map[string]interface{}{"id": float64(2)}, + }) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 2 { + t.Errorf("paginated JSON should emit 2 lines (NDJSON), got %d:\n%s", len(lines), buf.String()) + } +} + +func TestPaginatedFormatter_NDJSON(t *testing.T) { + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatNDJSON) + + pf.FormatPage([]interface{}{map[string]interface{}{"id": float64(1)}}) + out := strings.TrimSpace(buf.String()) + + var obj map[string]interface{} + if err := json.Unmarshal([]byte(out), &obj); err != nil { + t.Errorf("NDJSON paginated output should be valid JSON: %s", out) + } +} + +func TestPaginatedFormatter_Table(t *testing.T) { + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatTable) + + page1 := []interface{}{map[string]interface{}{"name": "Alice"}} + page2 := []interface{}{map[string]interface{}{"name": "Bob"}} + + pf.FormatPage(page1) + out1 := buf.String() + if !strings.Contains(out1, "─") { + t.Error("first table page should contain separator") + } + + buf.Reset() + pf.FormatPage(page2) + out2 := buf.String() + if strings.Contains(out2, "─") { + t.Error("continuation table page should not contain separator") + } + if !strings.Contains(out2, "Bob") { + t.Error("continuation table page should contain data") + } +} + +func TestPaginatedFormatter_CSV(t *testing.T) { + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatCSV) + + page1 := []interface{}{map[string]interface{}{"name": "Alice"}} + page2 := []interface{}{map[string]interface{}{"name": "Bob"}} + + pf.FormatPage(page1) + lines1 := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines1) != 2 { + t.Errorf("first CSV page should have header + data, got %d lines", len(lines1)) + } + + buf.Reset() + pf.FormatPage(page2) + lines2 := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines2) != 1 { + t.Errorf("continuation CSV page should have only data, got %d lines", len(lines2)) + } +} + +func TestPaginatedFormatter_ColumnConsistency(t *testing.T) { + // Page 1 has {a, b}, page 2 has {a, b, c} — c should be ignored in CSV + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatCSV) + + pf.FormatPage([]interface{}{map[string]interface{}{"a": "1", "b": "2"}}) + header := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")[0] + + buf.Reset() + pf.FormatPage([]interface{}{map[string]interface{}{"a": "3", "b": "4", "c": "5"}}) + dataLine := strings.TrimRight(buf.String(), "\n") + + // Header and data should have same number of columns + headerCols := strings.Count(header, ",") + 1 + dataCols := strings.Count(dataLine, ",") + 1 + if headerCols != dataCols { + t.Errorf("column count mismatch: header has %d, data has %d\nheader: %s\ndata: %s", + headerCols, dataCols, header, dataLine) + } +} + +func TestExtractItems(t *testing.T) { + // Standard Lark response + data := map[string]interface{}{ + "code": float64(0), + "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": float64(1)}, + map[string]interface{}{"id": float64(2)}, + }, + "has_more": true, + "page_token": "abc", + }, + } + + items := ExtractItems(data) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + + // Different array field + data2 := map[string]interface{}{ + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"user_id": "u1"}, + }, + }, + } + + items2 := ExtractItems(data2) + if len(items2) != 1 { + t.Fatalf("expected 1 member, got %d", len(items2)) + } + + // Already an array + arr := []interface{}{"a", "b"} + items3 := ExtractItems(arr) + if len(items3) != 2 { + t.Fatalf("expected 2 items from raw array, got %d", len(items3)) + } + + // Non-response + items4 := ExtractItems("string") + if items4 != nil { + t.Fatalf("expected nil for non-response, got %v", items4) + } + + // No data field and no array field + items5 := ExtractItems(map[string]interface{}{"foo": "bar"}) + if items5 != nil { + t.Fatalf("expected nil for no data/array field, got %v", items5) + } + + // Direct map with array field (shortcut data like {"members":[…], "total":5}) + directMap := map[string]interface{}{ + "members": []interface{}{map[string]interface{}{"name": "Alice"}}, + "total": float64(1), + "has_more": false, + "page_token": "", + } + items6 := ExtractItems(directMap) + if len(items6) != 1 { + t.Fatalf("expected 1 item from direct map, got %d", len(items6)) + } + + // Direct map — plain array passed directly (e.g. calendar freebusy items) + plainArr := []interface{}{ + map[string]interface{}{"start": "10:00", "end": "11:00"}, + } + items7 := ExtractItems(plainArr) + if len(items7) != 1 { + t.Fatalf("expected 1 item from plain array, got %d", len(items7)) + } +} + +func TestFormatValue_LegacyFormats(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + }, + }, + } + + // "data" parses to FormatJSON with ok=false + dataFmt, dataOK := ParseFormat("data") + if dataOK { + t.Error("ParseFormat('data') should return ok=false") + } + var buf2 bytes.Buffer + FormatValue(&buf2, data, dataFmt) + out2 := buf2.String() + if !strings.Contains(out2, "items") { + t.Errorf("ParseFormat('data') → JSON should output full response, got:\n%s", out2) + } + + // unknown format parses to FormatJSON with ok=false + fooFmt, fooOK := ParseFormat("foobar") + if fooOK { + t.Error("ParseFormat('foobar') should return ok=false") + } + var buf3 bytes.Buffer + FormatValue(&buf3, data, fooFmt) + out3 := buf3.String() + if !strings.Contains(out3, "items") { + t.Errorf("ParseFormat('foobar') → JSON should output full response, got:\n%s", out3) + } +} diff --git a/internal/output/format_type.go b/internal/output/format_type.go new file mode 100644 index 00000000..c78db914 --- /dev/null +++ b/internal/output/format_type.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import "strings" + +// Format represents an output format type. +type Format int + +const ( + FormatJSON Format = iota + FormatNDJSON + FormatTable + FormatCSV +) + +// ParseFormat parses a format string into a Format value. +// The second return value is false if the format string was not recognized, +// in which case FormatJSON is returned as default. +func ParseFormat(s string) (Format, bool) { + switch strings.ToLower(s) { + case "json", "": + return FormatJSON, true + case "ndjson": + return FormatNDJSON, true + case "table": + return FormatTable, true + case "csv": + return FormatCSV, true + default: + return FormatJSON, false + } +} + +// String returns the string representation of a Format. +func (f Format) String() string { + switch f { + case FormatNDJSON: + return "ndjson" + case FormatTable: + return "table" + case FormatCSV: + return "csv" + default: + return "json" + } +} diff --git a/internal/output/format_type_test.go b/internal/output/format_type_test.go new file mode 100644 index 00000000..1c57f114 --- /dev/null +++ b/internal/output/format_type_test.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import "testing" + +func TestParseFormat(t *testing.T) { + tests := []struct { + input string + want Format + wantOK bool + }{ + {"json", FormatJSON, true}, + {"JSON", FormatJSON, true}, + {"Json", FormatJSON, true}, + {"ndjson", FormatNDJSON, true}, + {"NDJSON", FormatNDJSON, true}, + {"Ndjson", FormatNDJSON, true}, + {"table", FormatTable, true}, + {"TABLE", FormatTable, true}, + {"Table", FormatTable, true}, + {"csv", FormatCSV, true}, + {"CSV", FormatCSV, true}, + {"Csv", FormatCSV, true}, + {"", FormatJSON, true}, + // Legacy/unknown values fall back to JSON with ok=false + {"data", FormatJSON, false}, + {"raw", FormatJSON, false}, + {"RAW", FormatJSON, false}, + {"DATA", FormatJSON, false}, + {"foobar", FormatJSON, false}, + {"xml", FormatJSON, false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, ok := ParseFormat(tt.input) + if got != tt.want { + t.Errorf("ParseFormat(%q) format = %v, want %v", tt.input, got, tt.want) + } + if ok != tt.wantOK { + t.Errorf("ParseFormat(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) + } + }) + } +} + +func TestFormatString(t *testing.T) { + tests := []struct { + format Format + want string + }{ + {FormatJSON, "json"}, + {FormatNDJSON, "ndjson"}, + {FormatTable, "table"}, + {FormatCSV, "csv"}, + {Format(99), "json"}, // unknown falls back + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := tt.format.String() + if got != tt.want { + t.Errorf("Format(%d).String() = %q, want %q", tt.format, got, tt.want) + } + }) + } +} diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go new file mode 100644 index 00000000..c5c5d10b --- /dev/null +++ b/internal/output/lark_errors.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +// Lark API generic error code constants. +// ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code +const ( + // Auth: token missing / invalid / expired. + LarkErrTokenMissing = 99991661 // Authorization header missing or empty + LarkErrTokenBadFmt = 99991671 // token format error (must start with "t-" or "u-") + LarkErrTokenInvalid = 99991668 // user_access_token invalid or expired + LarkErrATInvalid = 99991663 // access_token invalid (generic) + LarkErrTokenExpired = 99991677 // user_access_token expired, refresh to obtain a new one + + // Permission: scope not granted. + LarkErrAppScopeNotEnabled = 99991672 // app has not applied for the required API scope + LarkErrTokenNoPermission = 99991676 // token lacks the required scope + LarkErrUserScopeInsufficient = 99991679 // user has not granted the required scope + LarkErrUserNotAuthorized = 230027 // user not authorized + + // App credential / status. + LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect + LarkErrAppNotInUse = 99991662 // app is disabled or not installed in this tenant + LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation + + // Rate limit. + LarkErrRateLimit = 99991400 // request frequency limit exceeded + + // Refresh token errors (authn service). + LarkErrRefreshInvalid = 20026 // refresh_token invalid or v1 format + LarkErrRefreshExpired = 20037 // refresh_token expired + LarkErrRefreshRevoked = 20064 // refresh_token revoked + LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation) + LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable +) + +// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint). +// errType provides fine-grained classification in the JSON envelope; +// exitCode is kept coarse (ExitAuth or ExitAPI). +func ClassifyLarkError(code int, msg string) (int, string, string) { + switch code { + // auth: token missing / invalid / expired + case LarkErrTokenMissing, LarkErrTokenBadFmt: + return ExitAuth, "auth", "run: lark-cli auth login to re-authorize" + case LarkErrTokenInvalid, LarkErrATInvalid, LarkErrTokenExpired: + return ExitAuth, "auth", "run: lark-cli auth login to re-authorize" + + // permission: scope not granted + case LarkErrAppScopeNotEnabled, LarkErrTokenNoPermission, + LarkErrUserScopeInsufficient, LarkErrUserNotAuthorized: + return ExitAPI, "permission", "check app permissions or re-authorize: lark-cli auth login" + + // app credential / status + case LarkErrAppCredInvalid: + return ExitAuth, "config", "check app_id / app_secret: lark-cli config set" + case LarkErrAppNotInUse, LarkErrAppUnauthorized: + return ExitAuth, "app_status", "app is disabled or not installed — check developer console" + + // rate limit + case LarkErrRateLimit: + return ExitAPI, "rate_limit", "please try again later" + } + + return ExitAPI, "api_error", "" +} diff --git a/internal/output/print.go b/internal/output/print.go new file mode 100644 index 00000000..e26e5117 --- /dev/null +++ b/internal/output/print.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/larksuite/cli/internal/validate" +) + +// PrintJson prints data as formatted JSON to w. +func PrintJson(w io.Writer, data interface{}) { + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err) + return + } + fmt.Fprintln(w, string(b)) +} + +// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w. +func PrintNdjson(w io.Writer, data interface{}) { + emit := func(item interface{}) { + b, err := json.Marshal(item) + if err != nil { + fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err) + return + } + fmt.Fprintln(w, string(b)) + } + if arr, ok := data.([]interface{}); ok { + for _, item := range arr { + emit(item) + } + } else { + emit(data) + } +} + +func cellStr(val interface{}) string { + if val == nil { + return "" + } + var s string + switch v := val.(type) { + case string: + s = v + case json.Number: + s = v.String() + case float64: + if v == float64(int(v)) { + s = fmt.Sprintf("%d", int(v)) + } else { + s = fmt.Sprintf("%g", v) + } + case bool: + s = fmt.Sprintf("%v", v) + default: + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + s = string(b) + } + // Sanitize for terminal display: strip ANSI escapes, control chars, dangerous Unicode. + return validate.SanitizeForTerminal(s) +} + +// PrintTable prints rows as a table to w. +// Delegates to FormatAsTable for flattening, column union, and width handling. +func PrintTable(w io.Writer, rows []map[string]interface{}) { + if len(rows) == 0 { + fmt.Fprintln(w, "(no data)") + return + } + items := make([]interface{}, len(rows)) + for i, r := range rows { + items[i] = r + } + FormatAsTable(w, items) +} + +// PrintSuccess prints a success message to w. +func PrintSuccess(w io.Writer, msg string) { + fmt.Fprintf(w, "OK: %s\n", msg) +} + +// PrintError prints an error message to w. +func PrintError(w io.Writer, msg string) { + fmt.Fprintf(w, "ERROR: %s\n", msg) +} diff --git a/internal/output/table.go b/internal/output/table.go new file mode 100644 index 00000000..017f32dc --- /dev/null +++ b/internal/output/table.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "fmt" + "io" + "strings" +) + +const maxColWidth = 100 + +// FormatAsTable formats data as a table and writes it to w. +// - []interface{} (array of objects) → header + separator + rows +// - map[string]interface{} (single object) → key-value two-column table +// - empty array → "(empty)" +func FormatAsTable(w io.Writer, data interface{}) { + FormatAsTablePaginated(w, data, true) +} + +// FormatAsTablePaginated formats data as a table with pagination awareness. +// When isFirstPage is true, outputs the header; otherwise only data rows. +func FormatAsTablePaginated(w io.Writer, data interface{}, isFirstPage bool) { + rows, cols, isList := prepareRows(data) + if cols == nil { + if isList { + fmt.Fprintln(w, "(empty)") + } else { + // Not a list and not an object — print as JSON fallback + PrintJson(w, data) + } + return + } + + if len(rows) == 0 { + if isFirstPage { + fmt.Fprintln(w, "(empty)") + } + return + } + + if !isList { + // Single object: key-value two-column format + formatKeyValueTable(w, rows[0], cols) + return + } + + // Calculate column widths (clamped to maxColWidth) + widths := computeColumnWidths(rows, cols) + + if isFirstPage { + writeHeader(w, cols, widths) + } + + for _, row := range rows { + writeRow(w, row, cols, widths) + } +} + +// formatKeyValueTable renders a single object as a two-column key-value table. +func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) { + maxKeyWidth := 0 + for _, col := range cols { + kw := stringWidth(col) + if kw > maxKeyWidth { + maxKeyWidth = kw + } + } + + for _, col := range cols { + val := row[col] + val = truncateToWidth(val, maxColWidth) + fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val) + } +} + +// computeColumnWidths returns display widths for each column, clamped to maxColWidth. +func computeColumnWidths(rows []map[string]string, cols []string) []int { + widths := make([]int, len(cols)) + for i, col := range cols { + widths[i] = stringWidth(col) + } + for _, row := range rows { + for i, col := range cols { + cw := stringWidth(row[col]) + if cw > widths[i] { + widths[i] = cw + } + } + } + // Clamp to max + for i := range widths { + if widths[i] > maxColWidth { + widths[i] = maxColWidth + } + } + return widths +} + +// writeHeader writes the header row and separator line. +func writeHeader(w io.Writer, cols []string, widths []int) { + var header []string + var sep []string + for i, col := range cols { + header = append(header, padToWidth(col, widths[i])) + sep = append(sep, strings.Repeat("─", widths[i])) + } + fmt.Fprintln(w, strings.Join(header, " ")) + fmt.Fprintln(w, strings.Join(sep, " ")) +} + +// writeRow writes a single data row. +func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) { + var cells []string + for i, col := range cols { + val := truncateToWidth(row[col], widths[i]) + cells = append(cells, padToWidth(val, widths[i])) + } + fmt.Fprintln(w, strings.Join(cells, " ")) +} + +// padToWidth pads a string with spaces to reach the target display width. +func padToWidth(s string, targetWidth int) string { + sw := stringWidth(s) + if sw >= targetWidth { + return s + } + return s + strings.Repeat(" ", targetWidth-sw) +} diff --git a/internal/output/table_test.go b/internal/output/table_test.go new file mode 100644 index 00000000..4ecca7d1 --- /dev/null +++ b/internal/output/table_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestFormatAsTable_ObjectArray(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Alice", "age": float64(30)}, + map[string]interface{}{"name": "Bob", "age": float64(25)}, + } + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := buf.String() + + if !strings.Contains(out, "name") { + t.Errorf("output should contain 'name' header, got:\n%s", out) + } + if !strings.Contains(out, "age") { + t.Errorf("output should contain 'age' header, got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("output should contain 'Alice', got:\n%s", out) + } + if !strings.Contains(out, "Bob") { + t.Errorf("output should contain 'Bob', got:\n%s", out) + } + // Should contain separator with ─ + if !strings.Contains(out, "─") { + t.Errorf("output should contain ─ separator, got:\n%s", out) + } +} + +func TestFormatAsTable_SingleObject(t *testing.T) { + data := map[string]interface{}{ + "name": "Alice", + "age": float64(30), + } + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := buf.String() + + if !strings.Contains(out, "name") { + t.Errorf("output should contain 'name', got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("output should contain 'Alice', got:\n%s", out) + } +} + +func TestFormatAsTable_EmptyArray(t *testing.T) { + data := []interface{}{} + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := strings.TrimSpace(buf.String()) + + if out != "(empty)" { + t.Errorf("empty array should output '(empty)', got:\n%s", out) + } +} + +func TestFormatAsTable_NestedFlattening(t *testing.T) { + data := []interface{}{ + map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Alice", + }, + "id": float64(1), + }, + } + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := buf.String() + + if !strings.Contains(out, "user.name") { + t.Errorf("output should contain flattened 'user.name' column, got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("output should contain 'Alice', got:\n%s", out) + } +} + +func TestFormatAsTable_ColumnUnionFromAllRows(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"a": "1"}, + map[string]interface{}{"a": "2", "b": "3"}, + } + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := buf.String() + + if !strings.Contains(out, "b") { + t.Errorf("output should contain column 'b' from second row, got:\n%s", out) + } +} + +func TestFormatAsTablePaginated_FirstPage(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Alice"}, + } + + var buf bytes.Buffer + FormatAsTablePaginated(&buf, data, true) + out := buf.String() + + // First page should have header + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) < 3 { + t.Errorf("first page should have header + separator + data, got %d lines:\n%s", len(lines), out) + } +} + +func TestFormatAsTablePaginated_ContinuationPage(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Bob"}, + } + + var buf bytes.Buffer + FormatAsTablePaginated(&buf, data, false) + out := buf.String() + + // Continuation page should not have header/separator + if strings.Contains(out, "─") { + t.Errorf("continuation page should not contain separator, got:\n%s", out) + } + if !strings.Contains(out, "Bob") { + t.Errorf("continuation page should contain data, got:\n%s", out) + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 1 { + t.Errorf("continuation page should have 1 data line, got %d lines:\n%s", len(lines), out) + } +} + +func TestFormatAsTable_ColumnWidthClamp(t *testing.T) { + // Create a value longer than maxColWidth + longVal := strings.Repeat("x", 101) + data := []interface{}{ + map[string]interface{}{"col": longVal}, + } + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := buf.String() + + if strings.Contains(out, longVal) { + t.Errorf("output should not contain the full long value (should be truncated)") + } + if !strings.Contains(out, "…") { + t.Errorf("output should contain truncation marker …, got:\n%s", out) + } +} diff --git a/internal/registry/helpers.go b/internal/registry/helpers.go new file mode 100644 index 00000000..6f564adf --- /dev/null +++ b/internal/registry/helpers.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +// GetStrFromMap extracts a string value from map[string]interface{}. +func GetStrFromMap(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// GetStrSliceFromMap extracts a []string value from map[string]interface{}. +// Returns nil if the key is missing or the value is not a string slice. +func GetStrSliceFromMap(m map[string]interface{}, key string) []string { + if m == nil { + return nil + } + raw, ok := m[key].([]interface{}) + if !ok { + return nil + } + result := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + if len(result) == 0 { + return nil + } + return result +} + +// SelectRecommendedScope selects the known scope with the highest priority score +// (higher = more recommended / least privilege). +// Scopes not in the priority table are skipped to avoid recommending invalid/unknown scopes. +func SelectRecommendedScope(scopes []interface{}, identity string) string { + priorities := LoadScopePriorities() + bestScore := -1 + bestScope := "" + for _, s := range scopes { + str, ok := s.(string) + if !ok { + continue + } + score, exists := priorities[str] + if !exists { + continue // skip unknown scopes + } + if score > bestScore { + bestScore = score + bestScope = str + } + } + if bestScope != "" { + return bestScope + } + // Fallback: if no scope is in the priority table, return the first one. + if len(scopes) > 0 { + if s, ok := scopes[0].(string); ok { + return s + } + } + return "" +} diff --git a/internal/registry/loader.go b/internal/registry/loader.go new file mode 100644 index 00000000..a310326d --- /dev/null +++ b/internal/registry/loader.go @@ -0,0 +1,385 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "embed" + "encoding/json" + "math" + "path/filepath" + "runtime" + "sort" + "strconv" + "sync" + + "github.com/larksuite/cli/internal/core" +) + +//go:embed scope_priorities.json scope_overrides.json +var registryFS embed.FS + +// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in. +var embeddedMetaJSON []byte + +var ( + mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec + mergedProjectList []string // sorted project names + embeddedVersion string // version from embedded meta_data.json + initOnce sync.Once +) + +// Init initializes the registry with default brand (feishu). +// It is safe to call multiple times (sync.Once). +func Init() { + InitWithBrand(core.BrandFeishu) +} + +// InitWithBrand initializes the registry by loading embedded data and optionally +// overlaying cached remote data. The brand determines which remote API host to use. +// It is safe to call multiple times (sync.Once). +// Remote fetch errors are silently ignored when embedded data is available. +// If no embedded data exists and no cache is found, a synchronous fetch is attempted. +func InitWithBrand(brand core.LarkBrand) { + initOnce.Do(func() { + configuredBrand = brand + // 1. Load embedded meta_data.json as baseline (no-op if not compiled in) + loadEmbeddedIntoMerged() + // 2. Remote overlay + if remoteEnabled() && cacheWritable() { + // Check if brand changed since last cache + meta, metaErr := loadCacheMeta() + brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand) + + if !brandChanged { + if cached, err := loadCachedMerged(); err == nil { + overlayMergedServices(cached) + } + } + if len(mergedServices) == 0 || brandChanged { + // No data at all or brand changed — must sync fetch + doSyncFetch() + } else if shouldRefresh(meta) || metaErr != nil { + // Have embedded/cached data; refresh in background if TTL expired or first run + triggerBackgroundRefresh() + } + } + // 3. Build sorted project list + rebuildProjectList() + }) +} + +// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates +// mergedServices. No-op if meta_data.json is not compiled in. +func loadEmbeddedIntoMerged() { + if len(embeddedMetaJSON) == 0 { + return + } + var reg MergedRegistry + if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil { + return + } + embeddedVersion = reg.Version + overlayMergedServices(®) +} + +// rebuildProjectList rebuilds the sorted list of project names from mergedServices. +func rebuildProjectList() { + mergedProjectList = make([]string, 0, len(mergedServices)) + for name := range mergedServices { + mergedProjectList = append(mergedProjectList, name) + } + sort.Strings(mergedProjectList) +} + +var cachedAllScopes map[string][]string + +// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json +// for the given identity ("user" or "tenant"). Results are deduplicated and sorted. +func CollectAllScopesFromMeta(identity string) []string { + if cachedAllScopes == nil { + cachedAllScopes = make(map[string][]string) + } + if cached, ok := cachedAllScopes[identity]; ok { + return cached + } + + scopeSet := make(map[string]bool) + for _, project := range ListFromMetaProjects() { + spec := LoadFromMeta(project) + if spec == nil { + continue + } + resources, ok := spec["resources"].(map[string]interface{}) + if !ok { + continue + } + for _, resSpec := range resources { + resMap, ok := resSpec.(map[string]interface{}) + if !ok { + continue + } + methods, ok := resMap["methods"].(map[string]interface{}) + if !ok { + continue + } + for _, methodSpec := range methods { + methodMap, ok := methodSpec.(map[string]interface{}) + if !ok { + continue + } + // Check if method supports the requested identity + if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { + supported := false + for _, t := range tokens { + if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { + supported = true + break + } + } + if !supported { + continue + } + } + // Collect scopes + scopes, ok := methodMap["scopes"].([]interface{}) + if !ok { + continue + } + for _, s := range scopes { + if str, ok := s.(string); ok { + scopeSet[str] = true + } + } + } + } + } + + result := make([]string, 0, len(scopeSet)) + for s := range scopeSet { + result = append(result, s) + } + sort.Strings(result) + cachedAllScopes[identity] = result + return result +} + +// LoadFromMeta loads a service schema by project name. +// It returns data from the merged registry (embedded + cached remote overlay). +func LoadFromMeta(project string) map[string]interface{} { + Init() + return mergedServices[project] +} + +// ListFromMetaProjects lists available service project names (sorted). +// +//go:noinline +func ListFromMetaProjects() []string { + Init() + return mergedProjectList +} + +// DefaultScopeScore is the score assigned to scopes not in the priorities table. +// Higher score = more recommended. Unscored scopes get 0 (least preferred). +const DefaultScopeScore = 0 + +var cachedScopePriorities map[string]int +var cachedAutoApproveSet map[string]bool +var cachedPlatformAutoApprove map[string]bool // from scope_priorities.json only +var cachedOverrideAutoAllow map[string]bool // from scope_overrides.json allow only +var cachedOverrideAutoDeny map[string]bool // from scope_overrides.json deny only + +// scopePriorityEntry is used to parse scope_priorities.json entries. +type scopePriorityEntry struct { + ScopeName string `json:"scope_name"` + FinalScore string `json:"final_score"` + Recommend string `json:"recommend"` +} + +// LoadScopePriorities loads the scope priorities map from scope_priorities.json. +// Scores are stored as float strings (e.g. "52.42") and rounded to int. +func LoadScopePriorities() map[string]int { + if cachedScopePriorities != nil { + return cachedScopePriorities + } + + data, err := registryFS.ReadFile("scope_priorities.json") + if err != nil { + cachedScopePriorities = make(map[string]int) + return cachedScopePriorities + } + + var entries []scopePriorityEntry + if err := json.Unmarshal(data, &entries); err != nil { + cachedScopePriorities = make(map[string]int) + return cachedScopePriorities + } + + m := make(map[string]int, len(entries)) + for _, entry := range entries { + f, err := strconv.ParseFloat(entry.FinalScore, 64) + if err != nil { + continue + } + m[entry.ScopeName] = int(math.Round(f)) + } + + // Apply manual overrides from scope_overrides.json + if overrideData, err := registryFS.ReadFile("scope_overrides.json"); err == nil { + var wrapper struct { + PriorityOverrides map[string]int `json:"priority_overrides"` + } + if json.Unmarshal(overrideData, &wrapper) == nil { + for scope, score := range wrapper.PriorityOverrides { + m[scope] = score + } + } + } + + cachedScopePriorities = m + return cachedScopePriorities +} + +// LoadAutoApproveSet returns the set of auto-approve scope names. +// Sources (merged): recommend=="true" in scope_priorities.json +// + explicit allow/deny in scope_overrides.json. +func LoadAutoApproveSet() map[string]bool { + if cachedAutoApproveSet != nil { + return cachedAutoApproveSet + } + + m := make(map[string]bool) + + // 1. From scope_priorities.json (Recommend == "true") + if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil { + var entries []scopePriorityEntry + if json.Unmarshal(data, &entries) == nil { + for _, entry := range entries { + if entry.Recommend == "true" { + m[entry.ScopeName] = true + } + } + } + } + + // 2. From scope_overrides.json (recommend.allow/deny lists) + if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil { + var wrapper struct { + AutoApprove struct { + Allow []string `json:"allow"` + Deny []string `json:"deny"` + } `json:"recommend"` + } + if json.Unmarshal(data, &wrapper) == nil { + for _, s := range wrapper.AutoApprove.Allow { + m[s] = true + } + for _, s := range wrapper.AutoApprove.Deny { + delete(m, s) + } + } + } + + cachedAutoApproveSet = m + return cachedAutoApproveSet +} + +// LoadPlatformAutoApproveSet returns scopes with AutoApprove rule on the platform +// (from scope_priorities.json only, before overrides). +func LoadPlatformAutoApproveSet() map[string]bool { + if cachedPlatformAutoApprove != nil { + return cachedPlatformAutoApprove + } + m := make(map[string]bool) + if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil { + var entries []scopePriorityEntry + if json.Unmarshal(data, &entries) == nil { + for _, entry := range entries { + if entry.Recommend == "true" { + m[entry.ScopeName] = true + } + } + } + } + cachedPlatformAutoApprove = m + return cachedPlatformAutoApprove +} + +// LoadOverrideAutoApproveAllow returns scopes explicitly listed in +// scope_overrides.json recommend.allow (our desired additions). +func LoadOverrideAutoApproveAllow() map[string]bool { + if cachedOverrideAutoAllow != nil { + return cachedOverrideAutoAllow + } + m := make(map[string]bool) + if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil { + var wrapper struct { + AutoApprove struct { + Allow []string `json:"allow"` + } `json:"recommend"` + } + if json.Unmarshal(data, &wrapper) == nil { + for _, s := range wrapper.AutoApprove.Allow { + m[s] = true + } + } + } + cachedOverrideAutoAllow = m + return cachedOverrideAutoAllow +} + +// LoadOverrideAutoApproveDeny returns scopes explicitly listed in +// scope_overrides.json recommend.deny +func LoadOverrideAutoApproveDeny() map[string]bool { + if cachedOverrideAutoDeny != nil { + return cachedOverrideAutoDeny + } + m := make(map[string]bool) + if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil { + var wrapper struct { + AutoApprove struct { + Deny []string `json:"deny"` + } `json:"recommend"` + } + if json.Unmarshal(data, &wrapper) == nil { + for _, s := range wrapper.AutoApprove.Deny { + m[s] = true + } + } + } + cachedOverrideAutoDeny = m + return cachedOverrideAutoDeny +} + +// IsAutoApproveScope returns true if the scope has AutoApprove rule. +func IsAutoApproveScope(scope string) bool { + return LoadAutoApproveSet()[scope] +} + +// FilterAutoApproveScopes filters a scope list to only include auto-approve scopes. +func FilterAutoApproveScopes(scopes []string) []string { + autoApprove := LoadAutoApproveSet() + var result []string + for _, s := range scopes { + if autoApprove[s] { + result = append(result, s) + } + } + return result +} + +// GetScopeScore returns the priority score for a scope, or DefaultScopeScore if not found. +func GetScopeScore(scope string) int { + priorities := LoadScopePriorities() + if score, ok := priorities[scope]; ok { + return score + } + return DefaultScopeScore +} + +// GetRegistryDir returns the filesystem path to the registry directory. +// Used for finding skills files etc. +func GetRegistryDir() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Dir(filename) +} diff --git a/internal/registry/loader_embedded.go b/internal/registry/loader_embedded.go new file mode 100644 index 00000000..da41e079 --- /dev/null +++ b/internal/registry/loader_embedded.go @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import "embed" + +//go:embed meta_data*.json +var metaFS embed.FS + +//go:embed meta_data_default.json +var embeddedMetaDataDefaultJSON []byte + +func init() { + if data, err := metaFS.ReadFile("meta_data.json"); err == nil && len(data) > 0 { + embeddedMetaJSON = data + } else { + embeddedMetaJSON = embeddedMetaDataDefaultJSON + } +} diff --git a/internal/registry/meta_data_default.json b/internal/registry/meta_data_default.json new file mode 100644 index 00000000..a070ff22 --- /dev/null +++ b/internal/registry/meta_data_default.json @@ -0,0 +1 @@ +{"version":"0.0.0","services":[]} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 00000000..1ec7660f --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,557 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "sort" + "strings" + "testing" +) + +func TestLoadScopePriorities(t *testing.T) { + priorities := LoadScopePriorities() + if len(priorities) == 0 { + t.Fatal("expected non-empty priorities map") + } + t.Logf("Loaded %d scope priorities", len(priorities)) + + // Verify a known scope exists (im:message:recall is in the user's data) + if _, ok := priorities["im:message:recall"]; !ok { + t.Error("expected im:message:recall in priorities") + } +} + +func TestGetScopeScore(t *testing.T) { + // Known scope should have a real score + score := GetScopeScore("im:message:recall") + if score == DefaultScopeScore { + t.Errorf("expected real score for im:message:recall, got default %d", score) + } + t.Logf("im:message:recall score: %d", score) + + // Unknown scope should return default + score = GetScopeScore("unknown:scope:here") + if score != DefaultScopeScore { + t.Errorf("expected %d, got %d", DefaultScopeScore, score) + } + + // Override: im:chat:readonly should be overridden to 1 + score = GetScopeScore("im:chat:readonly") + if score != 1 { + t.Errorf("expected im:chat:readonly override score 1, got %d", score) + } +} + +func TestSelectRecommendedScope_PicksHighestScore(t *testing.T) { + priorities := LoadScopePriorities() + + // Find two scopes with known different scores + scopeA := "calendar:calendar:readonly" + scopeB := "calendar:calendar" + + scoreA, okA := priorities[scopeA] + scoreB, okB := priorities[scopeB] + if !okA || !okB { + t.Skipf("test scopes not in priorities (A=%v, B=%v)", okA, okB) + } + t.Logf("%s=%d, %s=%d", scopeA, scoreA, scopeB, scoreB) + + scopes := []interface{}{scopeB, scopeA} + result := SelectRecommendedScope(scopes, "user") + + // Should pick the higher-scored one (higher = more recommended) + if scoreA > scoreB { + if result != scopeA { + t.Errorf("expected %s (score %d), got %s", scopeA, scoreA, result) + } + } else { + if result != scopeB { + t.Errorf("expected %s (score %d), got %s", scopeB, scoreB, result) + } + } +} + +func TestSelectRecommendedScope_FallbackToFirst(t *testing.T) { + scopes := []interface{}{ + "zzz_unknown:scope:a", + "zzz_unknown:scope:b", + } + result := SelectRecommendedScope(scopes, "user") + // All unknown scopes get DefaultScopeScore; first one with that score wins + if result != "zzz_unknown:scope:a" { + t.Errorf("expected zzz_unknown:scope:a, got %s", result) + } +} + +func TestSelectRecommendedScope_Empty(t *testing.T) { + result := SelectRecommendedScope(nil, "user") + if result != "" { + t.Errorf("expected empty string, got %s", result) + } + + result = SelectRecommendedScope([]interface{}{}, "user") + if result != "" { + t.Errorf("expected empty string, got %s", result) + } +} + +func TestComputeMinimumScopeSet(t *testing.T) { + minSet := ComputeMinimumScopeSet("user") + if len(minSet) == 0 { + if len(ListFromMetaProjects()) == 0 { + t.Skip("no from_meta data available") + } + t.Fatal("expected non-empty minimum scope set") + } + + // Verify sorted + if !sort.StringsAreSorted(minSet) { + t.Error("expected sorted result") + } + + // Verify no duplicates + seen := make(map[string]bool) + for _, s := range minSet { + if seen[s] { + t.Errorf("duplicate scope: %s", s) + } + seen[s] = true + } + + t.Logf("Minimum scope set (%d scopes): %v", len(minSet), minSet) +} + +func TestComputeMinimumScopeSet_Tenant(t *testing.T) { + minSet := ComputeMinimumScopeSet("tenant") + if len(minSet) == 0 { + if len(ListFromMetaProjects()) == 0 { + t.Skip("no from_meta data available") + } + t.Fatal("expected non-empty minimum scope set for tenant") + } + t.Logf("Tenant minimum scope set (%d scopes): %v", len(minSet), minSet) +} + +func TestFilterScopes(t *testing.T) { + scopes := []string{ + "calendar:calendar.event:read", + "calendar:calendar:readonly", + "task:task:read", + "drive:drive.metadata:readonly", + } + + // Filter by domain + result := FilterScopes(scopes, []string{"calendar"}, nil) + if len(result) != 2 { + t.Errorf("expected 2 calendar scopes, got %d: %v", len(result), result) + } + + // Filter by permission + result = FilterScopes(scopes, nil, []string{"read"}) + for _, s := range result { + t.Logf("read-filtered: %s", s) + } +} + +func TestFilterScopes_WritePermission(t *testing.T) { + scopes := []string{ + "calendar:calendar.event:read", + "calendar:calendar:readonly", + "task:task:write", + "drive:drive:writeonly", + "drive:drive:write_only", + } + + result := FilterScopes(scopes, nil, []string{"write"}) + // "write" matches anything containing "write" (including writeonly, write_only) + if len(result) != 3 { + t.Errorf("expected 3 scopes matching 'write', got %d: %v", len(result), result) + } + + result = FilterScopes(scopes, nil, []string{"writeonly"}) + if len(result) != 2 { + t.Errorf("expected 2 writeonly scopes, got %d: %v", len(result), result) + } +} + +func TestFilterScopes_DomainAndPermission(t *testing.T) { + scopes := []string{ + "calendar:calendar.event:read", + "calendar:calendar:readonly", + "task:task:read", + "drive:drive.metadata:readonly", + } + + // Filter by domain AND permission + result := FilterScopes(scopes, []string{"calendar"}, []string{"readonly"}) + if len(result) != 1 || result[0] != "calendar:calendar:readonly" { + t.Errorf("expected [calendar:calendar:readonly], got %v", result) + } +} + +func TestFilterScopes_NilFilters(t *testing.T) { + scopes := []string{"a:b:c", "d:e:f"} + result := FilterScopes(scopes, nil, nil) + if len(result) != 2 { + t.Errorf("expected all scopes returned when no filters, got %d", len(result)) + } +} + +func TestFilterScopes_Empty(t *testing.T) { + result := FilterScopes(nil, nil, nil) + if result != nil { + t.Errorf("expected nil, got %v", result) + } +} + +func TestFilterScopes_TooFewParts(t *testing.T) { + scopes := []string{"onlyonepart", "two:parts"} + // Permission filter requires at least 3 parts + result := FilterScopes(scopes, nil, []string{"read"}) + if len(result) != 0 { + t.Errorf("expected 0 results for short scopes, got %v", result) + } +} + +// --- Auto-approve functions --- + +func TestLoadAutoApproveSet(t *testing.T) { + aaSet := LoadAutoApproveSet() + if len(aaSet) == 0 { + t.Fatal("expected non-empty auto-approve set") + } + + // From scope_overrides.json allow list + if !aaSet["calendar:calendar.event:create"] { + t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)") + } + + // Verify allow list entries are present + if !aaSet["sheets:spreadsheet:read"] { + t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)") + } + + t.Logf("Auto-approve set has %d scopes", len(aaSet)) +} + +func TestLoadPlatformAutoApproveSet(t *testing.T) { + paaSet := LoadPlatformAutoApproveSet() + // This should only include scopes from scope_priorities.json with AutoApprove rule. + // It does NOT apply deny overrides. + if len(paaSet) == 0 { + t.Fatal("expected non-empty platform auto-approve set") + } + + t.Logf("Platform auto-approve set has %d scopes", len(paaSet)) +} + +func TestLoadOverrideAutoApproveAllow(t *testing.T) { + allowSet := LoadOverrideAutoApproveAllow() + if len(allowSet) == 0 { + t.Fatal("expected non-empty override allow set") + } + + // Known entries from scope_overrides.json + if !allowSet["calendar:calendar.event:create"] { + t.Error("expected calendar:calendar.event:create in allow set") + } + if !allowSet["mail:event"] { + t.Error("expected mail:event in allow set") + } +} + +func TestLoadOverrideAutoApproveDeny(t *testing.T) { + denySet := LoadOverrideAutoApproveDeny() + // deny list may be empty if all entries are moved to _deny (commented out) + t.Logf("Override deny set has %d scopes", len(denySet)) +} + +func TestIsAutoApproveScope(t *testing.T) { + // Known auto-approve scope (in allow list) + if !IsAutoApproveScope("calendar:calendar.event:create") { + t.Error("expected calendar:calendar.event:create to be auto-approve") + } + + // Completely unknown scope + if IsAutoApproveScope("zzz:unknown:scope") { + t.Error("expected unknown scope to NOT be auto-approve") + } +} + +func TestFilterAutoApproveScopes(t *testing.T) { + scopes := []string{ + "calendar:calendar.event:create", // auto-approve (in allow list) + "zzz:unknown:scope", // not in auto-approve + "sheets:spreadsheet:read", // auto-approve (in allow list) + } + + result := FilterAutoApproveScopes(scopes) + if len(result) < 1 { + t.Fatal("expected at least 1 auto-approve scope in result") + } + + // Check that calendar:calendar.event:create is included + found := false + for _, s := range result { + if s == "calendar:calendar.event:create" { + found = true + } + // Ensure unknown scopes are not included + if s == "zzz:unknown:scope" { + t.Error("unknown scope should not be in auto-approve result") + } + } + if !found { + t.Error("expected calendar:calendar.event:create in result") + } +} + +func TestFilterAutoApproveScopes_Empty(t *testing.T) { + result := FilterAutoApproveScopes(nil) + if result != nil { + t.Errorf("expected nil, got %v", result) + } + + result = FilterAutoApproveScopes([]string{}) + if result != nil { + t.Errorf("expected nil for empty input, got %v", result) + } +} + +// --- Helper functions --- + +func TestGetStrFromMap(t *testing.T) { + m := map[string]interface{}{ + "key1": "value1", + "key2": 42, + "key3": nil, + } + + if v := GetStrFromMap(m, "key1"); v != "value1" { + t.Errorf("expected value1, got %s", v) + } + if v := GetStrFromMap(m, "key2"); v != "" { + t.Errorf("expected empty for non-string value, got %s", v) + } + if v := GetStrFromMap(m, "missing"); v != "" { + t.Errorf("expected empty for missing key, got %s", v) + } + if v := GetStrFromMap(nil, "key"); v != "" { + t.Errorf("expected empty for nil map, got %s", v) + } +} + +func TestGetRegistryDir(t *testing.T) { + dir := GetRegistryDir() + if dir == "" { + t.Error("expected non-empty registry dir") + } + t.Logf("Registry dir: %s", dir) +} + +// --- Scope collection functions --- + +func TestCollectAllScopesFromMeta(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + allScopes := CollectAllScopesFromMeta("user") + if len(allScopes) == 0 { + t.Fatal("expected non-empty scopes from from_meta") + } + + // Should be sorted + if !sort.StringsAreSorted(allScopes) { + t.Error("expected sorted result") + } + + // Should include more scopes than the minimum set (since minimum picks best per method) + minSet := ComputeMinimumScopeSet("user") + if len(allScopes) < len(minSet) { + t.Errorf("all scopes (%d) should be >= minimum set (%d)", len(allScopes), len(minSet)) + } + + t.Logf("All scopes from meta: %d (min set: %d)", len(allScopes), len(minSet)) +} + +func TestCollectAllScopesFromMeta_Caching(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + result1 := CollectAllScopesFromMeta("user") + result2 := CollectAllScopesFromMeta("user") + + if len(result1) != len(result2) { + t.Errorf("cached result length mismatch: %d vs %d", len(result1), len(result2)) + } +} + +func TestCollectScopesWithSources(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + // Use calendar project which is well-known + scopes, sources := CollectScopesWithSources([]string{"calendar"}, "user") + if len(scopes) == 0 { + t.Fatal("expected non-empty scopes for calendar") + } + + // Should be sorted + if !sort.StringsAreSorted(scopes) { + t.Error("expected sorted scopes") + } + + // Each scope should have a source + for _, s := range scopes { + src, ok := sources[s] + if !ok { + t.Errorf("scope %s has no source entry", s) + continue + } + if len(src.APIs) == 0 { + t.Errorf("scope %s has no API sources", s) + } + } + + t.Logf("Calendar scopes with sources: %d scopes", len(scopes)) +} + +func TestCollectScopesWithSources_EmptyProject(t *testing.T) { + scopes, sources := CollectScopesWithSources([]string{"nonexistent_project"}, "user") + if len(scopes) != 0 { + t.Errorf("expected empty scopes for nonexistent project, got %d", len(scopes)) + } + if len(sources) != 0 { + t.Errorf("expected empty sources for nonexistent project, got %d", len(sources)) + } +} + +func TestCollectCommandScopes(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + entries := CollectCommandScopes([]string{"calendar"}, "user") + if len(entries) == 0 { + t.Fatal("expected non-empty command entries for calendar") + } + + // Verify sorted by Command + for i := 1; i < len(entries); i++ { + if entries[i].Command < entries[i-1].Command { + t.Errorf("entries not sorted: %s < %s", entries[i].Command, entries[i-1].Command) + } + } + + // Verify each entry has scopes and type + for _, e := range entries { + if e.Command == "" { + t.Error("entry has empty command") + } + if e.Type != "api" { + t.Errorf("expected type 'api', got %q", e.Type) + } + if len(e.Scopes) == 0 { + t.Errorf("entry %s has no scopes", e.Command) + } + } + + t.Logf("Calendar command entries: %d", len(entries)) +} + +func TestCollectCommandScopes_EmptyProject(t *testing.T) { + entries := CollectCommandScopes([]string{"nonexistent_project"}, "user") + if len(entries) != 0 { + t.Errorf("expected empty entries for nonexistent project, got %d", len(entries)) + } +} + +func TestGetScopesForDomains(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + // GetScopesForDomains is a wrapper for CollectScopesForProjects + scopes := GetScopesForDomains([]string{"calendar"}, "user") + expected := CollectScopesForProjects([]string{"calendar"}, "user") + + if len(scopes) != len(expected) { + t.Errorf("GetScopesForDomains and CollectScopesForProjects differ: %d vs %d", len(scopes), len(expected)) + } +} + +func TestGetReadOnlyScopes(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + readOnly := GetReadOnlyScopes("user") + // May be empty if no read-only scopes exist, but should not panic + for _, s := range readOnly { + parts := strings.Split(s, ":") + if len(parts) < 3 { + t.Errorf("unexpected scope format (too few parts): %s", s) + continue + } + perm := parts[2] + if !strings.Contains(perm, "read") && perm != "readonly" { + t.Errorf("non-read scope in read-only result: %s", s) + } + } + + t.Logf("Read-only scopes: %d", len(readOnly)) +} + +func TestResolveScopesFromFilters(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + // Should behave like CollectScopesForProjects + FilterScopes + scopes := ResolveScopesFromFilters([]string{"calendar"}, []string{"read", "readonly"}, "user") + for _, s := range scopes { + parts := strings.Split(s, ":") + if len(parts) < 3 { + continue + } + perm := parts[2] + if !strings.Contains(perm, "read") && perm != "readonly" { + t.Errorf("non-read scope in filtered result: %s", s) + } + } + + t.Logf("Resolved filtered scopes: %d", len(scopes)) +} + +func TestCollectScopesForProjects_MultipleProjects(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) < 2 { + t.Skip("need at least 2 from_meta projects") + } + + // Multiple projects should yield more scopes than a single one + single := CollectScopesForProjects(projects[:1], "user") + multi := CollectScopesForProjects(projects[:2], "user") + + if len(multi) < len(single) { + t.Errorf("multi-project scopes (%d) should be >= single-project (%d)", len(multi), len(single)) + } +} + +func TestCollectScopesForProjects_NonexistentProject(t *testing.T) { + scopes := CollectScopesForProjects([]string{"nonexistent_project_xyz"}, "user") + if len(scopes) != 0 { + t.Errorf("expected empty scopes for nonexistent project, got %d", len(scopes)) + } +} diff --git a/internal/registry/remote.go b/internal/registry/remote.go new file mode 100644 index 00000000..135c1f43 --- /dev/null +++ b/internal/registry/remote.go @@ -0,0 +1,311 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" +) + +const ( + defaultMetaTTL = 86400 // seconds (24h) + maxResponseSize = 10 << 20 // 10 MB + fetchTimeout = 5 * time.Second +) + +// CacheMeta holds metadata about the cached remote_meta.json file. +type CacheMeta struct { + LastCheckAt int64 `json:"last_check_at"` + Version string `json:"version,omitempty"` + Brand string `json:"brand,omitempty"` +} + +// MergedRegistry is the top-level structure of remote_meta.json. +type MergedRegistry struct { + Version string `json:"version"` + Services []map[string]interface{} `json:"services"` +} + +// remoteResponse is the envelope returned by the remote API. +type remoteResponse struct { + Msg string `json:"msg"` + Data MergedRegistry `json:"data"` +} + +// configuredBrand is set by InitWithBrand and determines which API host to use. +var configuredBrand core.LarkBrand + +// --- configuration helpers --- + +// enableRemoteMeta controls whether remote API meta fetching is active. +// Flip to true when ready to roll out. +var enableRemoteMeta = true + +func remoteEnabled() bool { + if !enableRemoteMeta { + return false + } + return os.Getenv("LARKSUITE_CLI_REMOTE_META") != "off" +} + +// testMetaURL overrides the remote meta URL for testing. +var testMetaURL string + +func remoteMetaURL(version string) string { + if testMetaURL != "" { + return testMetaURL + } + var base string + switch configuredBrand { + case core.BrandLark: + base = "https://open.larksuite.com/api/tools/open/api_definition" + default: + base = "https://open.feishu.cn/api/tools/open/api_definition" + } + q := "protocol=meta&client_version=" + url.QueryEscape(build.Version) + if version != "" { + q += "&data_version=" + url.QueryEscape(version) + } + return base + "?" + q +} + +func metaTTL() time.Duration { + if s := os.Getenv("LARKSUITE_CLI_META_TTL"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n >= 0 { + return time.Duration(n) * time.Second + } + } + return defaultMetaTTL * time.Second +} + +// --- cache path helpers --- + +func cacheDir() string { + return filepath.Join(core.GetConfigDir(), "cache") +} + +func cachePath() string { + return filepath.Join(cacheDir(), "remote_meta.json") +} + +func cacheMetaPath() string { + return filepath.Join(cacheDir(), "remote_meta.meta.json") +} + +// cacheWritable checks if the cache directory is writable. +// Returns false if the directory cannot be created or written to. +func cacheWritable() bool { + dir := cacheDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return false + } + probe := filepath.Join(dir, ".probe") + if err := os.WriteFile(probe, []byte{}, 0644); err != nil { + return false + } + os.Remove(probe) + return true +} + +// --- cache I/O --- + +func loadCacheMeta() (CacheMeta, error) { + var meta CacheMeta + data, err := os.ReadFile(cacheMetaPath()) + if err != nil { + return meta, err + } + if err = json.Unmarshal(data, &meta); err != nil { + return meta, err + } + return meta, nil +} + +func saveCacheMeta(meta CacheMeta) error { + if err := os.MkdirAll(cacheDir(), 0700); err != nil { + return err + } + data, err := json.Marshal(meta) + if err != nil { + return err + } + return validate.AtomicWrite(cacheMetaPath(), data, 0644) +} + +func loadCachedMerged() (*MergedRegistry, error) { + path := cachePath() + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var reg MergedRegistry + if err := json.Unmarshal(data, ®); err != nil { + // Cache corrupted — remove it so next run triggers a fresh fetch + os.Remove(path) + os.Remove(cacheMetaPath()) + return nil, err + } + return ®, nil +} + +func saveCachedMerged(data []byte, meta CacheMeta) error { + if err := os.MkdirAll(cacheDir(), 0700); err != nil { + return err + } + if err := validate.AtomicWrite(cachePath(), data, 0644); err != nil { + return err + } + return saveCacheMeta(meta) +} + +// --- HTTP fetch --- + +// fetchRemoteMerged fetches the remote API definition. +// localVersion is sent as data_version query param for server-side version comparison. +// Returns (data, reg, err). A nil reg means the version is unchanged (not modified). +func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) { + client := &http.Client{Timeout: fetchTimeout} + req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil) + if err != nil { + return nil, nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, nil, &httpError{StatusCode: resp.StatusCode} + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize)) + if err != nil { + return nil, nil, err + } + + // Parse the envelope response + var envelope remoteResponse + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, nil, err + } + if envelope.Msg != "succeeded" { + return nil, nil, fmt.Errorf("remote meta: unexpected msg %q", envelope.Msg) + } + + // If data.Services is nil, the version is up-to-date (not modified) + if envelope.Data.Services == nil { + return nil, nil, nil + } + + // Re-marshal just the data portion for caching + dataBytes, err := json.Marshal(envelope.Data) + if err != nil { + return nil, nil, err + } + + return dataBytes, &envelope.Data, nil +} + +type httpError struct { + StatusCode int +} + +func (e *httpError) Error() string { + return "remote meta: HTTP " + strconv.Itoa(e.StatusCode) +} + +// --- sync fetch (no embedded, no cache) --- + +// doSyncFetch performs a blocking fetch for first-run without embedded data. +func doSyncFetch() { + fmt.Fprintf(os.Stderr, "Fetching API metadata...\n") + data, reg, err := fetchRemoteMerged(embeddedVersion) + if err != nil || reg == nil { + // Write meta even on failure so we don't retry every invocation within TTL + _ = saveCacheMeta(CacheMeta{ + LastCheckAt: time.Now().Unix(), + Brand: string(configuredBrand), + }) + return + } + meta := CacheMeta{ + LastCheckAt: time.Now().Unix(), + Version: reg.Version, + Brand: string(configuredBrand), + } + _ = saveCachedMerged(data, meta) + overlayMergedServices(reg) +} + +// --- background refresh --- + +var refreshOnce sync.Once + +func triggerBackgroundRefresh() { + refreshOnce.Do(func() { + go doBackgroundRefresh() + }) +} + +func doBackgroundRefresh() { + defer func() { _ = recover() }() + meta, _ := loadCacheMeta() + version := meta.Version + if version == "" { + version = embeddedVersion + } + data, reg, err := fetchRemoteMerged(version) + if err != nil { + // On error, update last_check_at to avoid retrying every invocation + meta.LastCheckAt = time.Now().Unix() + _ = saveCacheMeta(meta) + return + } + if reg == nil { + // Version unchanged — just update check time + meta.LastCheckAt = time.Now().Unix() + _ = saveCacheMeta(meta) + return + } + newMeta := CacheMeta{ + LastCheckAt: time.Now().Unix(), + Version: reg.Version, + Brand: string(configuredBrand), + } + _ = saveCachedMerged(data, newMeta) +} + +// shouldRefresh returns true if the cache TTL has expired. +func shouldRefresh(meta CacheMeta) bool { + if meta.LastCheckAt == 0 { + return true + } + return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL() +} + +// overlayMergedServices merges remote services into the in-memory map. +// Remote entries override embedded entries with the same name. +func overlayMergedServices(reg *MergedRegistry) { + for _, svc := range reg.Services { + name, ok := svc["name"].(string) + if !ok || name == "" { + continue + } + mergedServices[name] = svc + } +} diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go new file mode 100644 index 00000000..1aa0f51a --- /dev/null +++ b/internal/registry/remote_test.go @@ -0,0 +1,480 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/larksuite/cli/internal/core" +) + +// resetInit resets the package-level state so each test starts fresh. +func resetInit() { + initOnce = sync.Once{} + mergedServices = make(map[string]map[string]interface{}) + mergedProjectList = nil + cachedAllScopes = nil + refreshOnce = sync.Once{} + configuredBrand = "" + enableRemoteMeta = true // tests exercise remote logic + testMetaURL = "" +} + +// hasEmbeddedData returns true if meta_data.json is compiled in. +func hasEmbeddedData() bool { + return len(embeddedMetaJSON) > 0 +} + +// testRegistry returns a minimal MergedRegistry with one service. +func testRegistry(name string) MergedRegistry { + return MergedRegistry{ + Version: "test-1.0", + Services: []map[string]interface{}{ + { + "name": name, + "version": "v1", + "title": name + " API", + "servicePath": "/open-apis/" + name + "/v1", + "resources": map[string]interface{}{}, + }, + }, + } +} + +// testCacheJSON returns a minimal valid MergedRegistry JSON (for cache files). +func testCacheJSON(name string) []byte { + data, _ := json.Marshal(testRegistry(name)) + return data +} + +// testEnvelopeJSON returns the remote API envelope format: {"msg":"succeeded","data":{...}}. +func testEnvelopeJSON(name string) []byte { + resp := remoteResponse{ + Msg: "succeeded", + Data: testRegistry(name), + } + data, _ := json.Marshal(resp) + return data +} + +// testEnvelopeNotModifiedJSON returns an envelope with empty data (version match). +func testEnvelopeNotModifiedJSON() []byte { + data, _ := json.Marshal(map[string]interface{}{ + "msg": "succeeded", + "data": map[string]interface{}{}, + }) + return data +} + +func TestColdStart_UsesEmbedded(t *testing.T) { + if !hasEmbeddedData() { + t.Skip("no embedded from_meta data") + } + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "off") + + Init() + + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Fatal("expected embedded projects, got none") + } + spec := LoadFromMeta("calendar") + if spec == nil { + t.Fatal("expected calendar spec from embedded data") + } +} + +func TestColdStart_NoEmbedded_SyncFetch(t *testing.T) { + if hasEmbeddedData() { + t.Skip("embedded data present, skipping no-embedded test") + } + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write(testEnvelopeJSON("remote_calendar")) + })) + defer ts.Close() + testMetaURL = ts.URL + + Init() + + if spec := LoadFromMeta("remote_calendar"); spec == nil { + t.Fatal("expected remote_calendar from sync fetch") + } +} + +func TestRemoteOff_SkipsRemoteLogic(t *testing.T) { + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "off") + + // Create a fake cache that should NOT be loaded + cDir := filepath.Join(tmp, "cache") + os.MkdirAll(cDir, 0700) + os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("fake_remote_svc"), 0644) + + Init() + + // "fake_remote_svc" should not be loaded when remote is off + if spec := LoadFromMeta("fake_remote_svc"); spec != nil { + t.Error("expected fake_remote_svc to NOT be loaded when remote is off") + } +} + +func TestCacheHit_WithinTTL(t *testing.T) { + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + t.Setenv("LARKSUITE_CLI_META_TTL", "3600") + + // Pre-seed cache with a custom service + cDir := filepath.Join(tmp, "cache") + os.MkdirAll(cDir, 0700) + os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("custom_svc"), 0644) + meta := CacheMeta{LastCheckAt: time.Now().Unix()} + metaData, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644) + + // Point META_URL to a server that would fail if contacted + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("server should not be contacted when cache is within TTL") + w.WriteHeader(500) + })) + defer ts.Close() + testMetaURL = ts.URL + + Init() + + // custom_svc should be loaded from cache overlay + if spec := LoadFromMeta("custom_svc"); spec == nil { + t.Error("expected custom_svc from cache overlay") + } + // Embedded projects should still be present (if compiled in) + if hasEmbeddedData() { + if spec := LoadFromMeta("calendar"); spec == nil { + t.Error("expected calendar from embedded data") + } + } +} + +func TestNetworkError_SilentDegradation(t *testing.T) { + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + t.Setenv("LARKSUITE_CLI_META_TTL", "0") // Always refresh + + // Pre-seed cache so we have data to fall back on + cDir := filepath.Join(tmp, "cache") + os.MkdirAll(cDir, 0700) + os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("cached_svc"), 0644) + meta := CacheMeta{LastCheckAt: time.Now().Add(-2 * time.Hour).Unix()} + metaData, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644) + + // Use a mock server that returns an error immediately (instead of 127.0.0.1:1 which + // may hang up to fetchTimeout=5s, leaking the background goroutine into subsequent tests). + fetched := make(chan struct{}, 1) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + select { + case fetched <- struct{}{}: + default: + } + })) + defer ts.Close() + testMetaURL = ts.URL + + // Should not panic or error + Init() + + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Fatal("expected projects after network error") + } + if spec := LoadFromMeta("cached_svc"); spec == nil { + t.Fatal("expected cached_svc after network error") + } + + // Wait for background goroutine to finish so it doesn't leak into subsequent tests. + select { + case <-fetched: + case <-time.After(5 * time.Second): + } + time.Sleep(50 * time.Millisecond) +} + +func TestShouldRefresh(t *testing.T) { + t.Setenv("LARKSUITE_CLI_META_TTL", "60") + + // Zero means never checked + if !shouldRefresh(CacheMeta{}) { + t.Error("expected shouldRefresh=true for zero LastCheckAt") + } + + // Recent check — no refresh needed + if shouldRefresh(CacheMeta{LastCheckAt: time.Now().Unix()}) { + t.Error("expected shouldRefresh=false for recent check") + } + + // Old check — refresh needed + if !shouldRefresh(CacheMeta{LastCheckAt: time.Now().Add(-2 * time.Minute).Unix()}) { + t.Error("expected shouldRefresh=true for old check") + } +} + +func TestRemoteEnabled(t *testing.T) { + // When feature flag is off, always disabled + enableRemoteMeta = false + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + if remoteEnabled() { + t.Error("expected disabled when feature flag is off") + } + + // When feature flag is on, env var controls + enableRemoteMeta = true + + t.Setenv("LARKSUITE_CLI_REMOTE_META", "off") + if remoteEnabled() { + t.Error("expected disabled when set to 'off'") + } + + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + if !remoteEnabled() { + t.Error("expected enabled when set to 'on'") + } + + t.Setenv("LARKSUITE_CLI_REMOTE_META", "") + if !remoteEnabled() { + t.Error("expected enabled when empty (default on)") + } +} + +func TestMetaTTL(t *testing.T) { + t.Setenv("LARKSUITE_CLI_META_TTL", "120") + if ttl := metaTTL(); ttl != 120*time.Second { + t.Errorf("expected 120s, got %v", ttl) + } + + t.Setenv("LARKSUITE_CLI_META_TTL", "") + if ttl := metaTTL(); ttl != defaultMetaTTL*time.Second { + t.Errorf("expected default %ds, got %v", defaultMetaTTL, ttl) + } + + t.Setenv("LARKSUITE_CLI_META_TTL", "invalid") + if ttl := metaTTL(); ttl != defaultMetaTTL*time.Second { + t.Errorf("expected default on invalid input, got %v", ttl) + } +} + +func TestOverlayMergedServices(t *testing.T) { + resetInit() + mergedServices = make(map[string]map[string]interface{}) + mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"} + + reg := &MergedRegistry{ + Services: []map[string]interface{}{ + {"name": "existing", "version": "v2"}, + {"name": "brand_new", "version": "v1"}, + }, + } + overlayMergedServices(reg) + + // existing should be overridden + if v := mergedServices["existing"]["version"].(string); v != "v2" { + t.Errorf("expected existing to be overridden to v2, got %s", v) + } + // brand_new should be added + if _, ok := mergedServices["brand_new"]; !ok { + t.Error("expected brand_new to be added") + } +} + +func TestFetchRemoteMerged_200(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write(testEnvelopeJSON("fetched_svc")) + })) + defer ts.Close() + testMetaURL = ts.URL + + data, reg, err := fetchRemoteMerged("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reg == nil { + t.Fatal("expected non-nil registry") + } + if data == nil { + t.Fatal("expected non-nil data") + } + if reg.Version != "test-1.0" { + t.Errorf("expected version test-1.0, got %s", reg.Version) + } +} + +func TestFetchRemoteMerged_VersionMatch(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write(testEnvelopeNotModifiedJSON()) + })) + defer ts.Close() + testMetaURL = ts.URL + + data, reg, err := fetchRemoteMerged("test-1.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reg != nil { + t.Error("expected nil registry for version match (not modified)") + } + if data != nil { + t.Error("expected nil data for version match") + } +} + +func TestFetchRemoteMerged_ServerError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(503) + })) + defer ts.Close() + testMetaURL = ts.URL + + _, _, err := fetchRemoteMerged("") + if err == nil { + t.Fatal("expected error for 503") + } + httpErr, ok := err.(*httpError) + if !ok { + t.Fatalf("expected *httpError, got %T", err) + } + if httpErr.StatusCode != 503 { + t.Errorf("expected 503, got %d", httpErr.StatusCode) + } +} + +func TestCorruptedCache_SelfHeals(t *testing.T) { + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + + // Write corrupted cache + cDir := filepath.Join(tmp, "cache") + os.MkdirAll(cDir, 0700) + os.WriteFile(filepath.Join(cDir, "remote_meta.json"), []byte("not json{{{"), 0644) + meta := CacheMeta{LastCheckAt: time.Now().Unix()} + metaData, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644) + + // loadCachedMerged should fail and remove the corrupted files + _, err := loadCachedMerged() + if err == nil { + t.Fatal("expected error for corrupted cache") + } + + // Corrupted files should be deleted + if _, err := os.Stat(filepath.Join(cDir, "remote_meta.json")); !os.IsNotExist(err) { + t.Error("expected corrupted remote_meta.json to be deleted") + } + if _, err := os.Stat(filepath.Join(cDir, "remote_meta.meta.json")); !os.IsNotExist(err) { + t.Error("expected remote_meta.meta.json to be deleted") + } +} + +func TestFetchRemoteMerged_InvalidJSON(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte("not json")) + })) + defer ts.Close() + testMetaURL = ts.URL + + _, _, err := fetchRemoteMerged("") + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestBrandSwitchInvalidatesCache(t *testing.T) { + // Wait for any background goroutines from previous tests to settle + time.Sleep(200 * time.Millisecond) + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + t.Setenv("LARKSUITE_CLI_META_TTL", "3600") + + // Pre-seed cache with feishu brand + cDir := filepath.Join(tmp, "cache") + os.MkdirAll(cDir, 0700) + os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("feishu_svc"), 0644) + meta := CacheMeta{LastCheckAt: time.Now().Unix(), Version: "test-1.0", Brand: "feishu"} + metaData, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644) + + // Server returns lark-specific data + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write(testEnvelopeJSON("lark_svc")) + })) + defer ts.Close() + testMetaURL = ts.URL + + // Init with lark brand — should invalidate feishu cache and sync fetch + InitWithBrand(core.BrandLark) + + // The old feishu_svc should NOT be loaded from stale cache + // The new lark_svc from sync fetch should be available + if spec := LoadFromMeta("lark_svc"); spec == nil { + t.Error("expected lark_svc after brand switch sync fetch") + } +} + +func TestRemoteMetaURL_BrandSpecific(t *testing.T) { + testMetaURL = "" + + // Default URL (feishu) with no version + configuredBrand = core.BrandFeishu + u := remoteMetaURL("") + if !strings.Contains(u, "open.feishu.cn") { + t.Errorf("expected feishu URL, got %s", u) + } + if strings.Contains(u, "data_version") { + t.Errorf("expected no data_version param for empty version, got %s", u) + } + + // Lark brand with version param + configuredBrand = core.BrandLark + u = remoteMetaURL("1.0.3") + if !strings.Contains(u, "open.larksuite.com") { + t.Errorf("expected lark URL, got %s", u) + } + if !strings.Contains(u, "data_version=1.0.3") { + t.Errorf("expected data_version=1.0.3, got %s", u) + } + + // testMetaURL override takes precedence + testMetaURL = "http://custom.example.com/meta" + u = remoteMetaURL("ignored") + if u != "http://custom.example.com/meta" { + t.Errorf("expected testMetaURL override, got %s", u) + } +} diff --git a/internal/registry/scope_overrides.json b/internal/registry/scope_overrides.json new file mode 100644 index 00000000..8287248e --- /dev/null +++ b/internal/registry/scope_overrides.json @@ -0,0 +1,50 @@ +{ + "priority_overrides": { + "im:chat:readonly": 1, + "im:message:send_as_bot": 1, + "calendar:calendar:read": 70, + "calendar:calendar:readonly": 1, + "sheets:spreadsheet:write_only": 45, + "docs:document.comment:delete": 60, + "drive:drive:readonly": 1, + "docs:doc:readonly": 1, + "sheets:spreadsheet:readonly": 1, + "vc:meeting:readonly": 1, + "vc:meeting.meetingevent:read": 75 + }, + "recommend": { + "allow": [ + "calendar:calendar.event:create", + "calendar:calendar.event:delete", + "calendar:calendar.event:read", + "calendar:calendar.event:update", + "calendar:calendar.free_busy:read", + "calendar:calendar:create", + "calendar:calendar:delete", + "calendar:calendar:read", + "calendar:calendar:update", + "contact:user.basic_profile:readonly", + "mail:event", + "mail:user_mailbox.mail_contact:read", + "mail:user_mailbox.mail_contact:write", + "mail:user_mailbox.message.address:read", + "mail:user_mailbox.message.body:read", + "mail:user_mailbox.message.subject:read", + "mail:user_mailbox.message:readonly" + ], + "deny": [ + "im:chat", + "im:message.send_as_user" + ], + "_deny": [ + "mail:user_mailbox.folder:read", + "mail:user_mailbox.folder:write", + "mail:user_mailbox.message:modify", + "mail:user_mailbox.message:readonly", + "mail:user_mailbox.message:send", + "mail:user_mailbox:readonly", + "sheets:spreadsheet", + "sheets:spreadsheet:readonly" + ] + } +} \ No newline at end of file diff --git a/internal/registry/scope_priorities.json b/internal/registry/scope_priorities.json new file mode 100644 index 00000000..ef73add3 --- /dev/null +++ b/internal/registry/scope_priorities.json @@ -0,0 +1,5522 @@ +[ + { + "scope_name": "directory:employee.work.resign_remark:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "im:message:send_multi_depts", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "hire:evaluation:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "calendar:time_off:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.probation_exist:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department:list", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.gender:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.data_source:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:group", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "document_ai:id_card:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:process:read", + "final_score": "49.7643", + "recommend": "false" + }, + { + "scope_name": "corehr:work_calendar:read", + "final_score": "71.6425", + "recommend": "false" + }, + { + "scope_name": "cardkit:card:read", + "final_score": "71.8295", + "recommend": "false" + }, + { + "scope_name": "app_engine:attachment:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "app_engine:approval:read", + "final_score": "57.9638", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.duration_type:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:talent_tag:readonly", + "final_score": "79.8295", + "recommend": "false" + }, + { + "scope_name": "trust_party:collaboration_rule:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.cost_center:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.work_country_or_region:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "minutes:minutes", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "admin:badge.grant", + "final_score": "62.7507", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.compensation_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.abnormal_reason_field:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "okr:okr.progress:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:object.record:write", + "final_score": "59.5638", + "recommend": "false" + }, + { + "scope_name": "directory:employee.resign:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:orgrole_info:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:record_permission.member:write", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "application:bot.menu:readonly", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "search:data_source", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employee.update:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.actual_probation_end_date:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "contact:department.base:readonly", + "final_score": "74.1705", + "recommend": "true" + }, + { + "scope_name": "im:chat.moderation:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "approval:approval.list:readonly", + "final_score": "61.4918", + "recommend": "false" + }, + { + "scope_name": "im:message:update", + "final_score": "77.7705", + "recommend": "true" + }, + { + "scope_name": "directory:department.count:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:food_produce_license:recoginze", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "directory:employee.work.job_title:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "directory:place.base:read", + "final_score": "61.4918", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.signing_type:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "payroll:cost_allocation_report:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:message:send_multi_users", + "final_score": "69.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:position.job_family:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "docs:permission.member:auth", + "final_score": "71.8295", + "recommend": "true" + }, + { + "scope_name": "hire:site_application:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "approval:instance.comment", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.compensation_type:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.custom_field.apaas_id__c:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.dependent:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:position.employee_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.custom_field:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_archive:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job.job_level:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.job_level:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.position:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "mdm:country_region:read", + "final_score": "71.8295", + "recommend": "false" + }, + { + "scope_name": "im:chat.collab_plugins:write_only", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "contact:department.organize:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:department.organize.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department.create:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet:readonly", + "final_score": "57.0000", + "recommend": "true" + }, + { + "scope_name": "hire:offer_selection_object", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "payroll:external_datasource_configuration:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.is_disabled:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "search:data_schemas:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:site_application", + "final_score": "46.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.position:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docx:document", + "final_score": "44.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:pre_hire:transit_tasks", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet.meta:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "application:application.app_usage_stats.overview:readonly", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.active_status:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:job_family:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_end_date:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.service_company:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "spark:data.record.change:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "aily:file:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "app_engine:role.member:write", + "final_score": "72.7705", + "recommend": "true" + }, + { + "scope_name": "search:app", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.national_id:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "base:app:create", + "final_score": "70.4295", + "recommend": "true" + }, + { + "scope_name": "contact:user.gender:readonly", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "docs:document:copy", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.status_message_v2:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.working_calendar:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes.basic:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.archive_cpst_plan:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.geo:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:cost_allocation:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "task:comment:write", + "final_score": "70.7507", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation_archive_detail.plan:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "admin:app.admin:readonly", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "hire:note", + "final_score": "44.4430", + "recommend": "false" + }, + { + "scope_name": "base:workspace:list", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "hire:subject:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "application:application.app_version:readonly", + "final_score": "63.9638", + "recommend": "false" + }, + { + "scope_name": "docs:permission.member:create", + "final_score": "72.7705", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.company:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_number:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mdm:vendor", + "final_score": "41.6590", + "recommend": "false" + }, + { + "scope_name": "im:chat:create", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "corehr:additional_job.job_level:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "slides:presentation:update", + "final_score": "59.5638", + "recommend": "true" + }, + { + "scope_name": "corehr:job_data:write", + "final_score": "59.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:person.email:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:file:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "docx:document:write_only", + "final_score": "52.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:probation.probation_expected_end_date:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "payroll:external_datasource_record:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.martyr_family:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "drive:drive:version:readonly", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "payroll:payment_activity:monitor", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "drive:file", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:person.address:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "approval:external_instance", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "contact:department.hrbp:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:external_referral_reward", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "search:dataset:create", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.custom_field:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.event:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "hire:tripartite_agreement", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "document_ai:health_certificate:recognize", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "im:url_preview.update", + "final_score": "72.7705", + "recommend": "true" + }, + { + "scope_name": "vc:meeting.search:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "attendance:task:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "performance:metric:write", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.job_level:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.message:send", + "final_score": "50.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:person.race:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.insurance:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.cost_center:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:hkm_mainland_travel_permit:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "aily:session:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.event:read", + "final_score": "50.5853", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.employee_subtype:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.is_admin:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:leave_grant:write", + "final_score": "67.2425", + "recommend": "false" + }, + { + "scope_name": "corehr:person.email:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet:write_only", + "final_score": "52.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.probation_exist:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:contract.company:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.probation_start_date:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_archive_detail.items:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "performance:semester_user:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.message:modify", + "final_score": "57.6000", + "recommend": "true" + }, + { + "scope_name": "docs:permission.member:delete", + "final_score": "71.8295", + "recommend": "true" + }, + { + "scope_name": "directory:department.idconvert:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "search:dataset.docs:create", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.marital_status:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:employee.job_data:read", + "final_score": "67.1507", + "recommend": "false" + }, + { + "scope_name": "im:chat.tabs:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:person.religion:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "application:application.contacts_range:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.office_address:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:doc", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "contact:job_level:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "task:task:readonly", + "final_score": "78.4430", + "recommend": "true" + }, + { + "scope_name": "corehr:person.political_affiliation:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.offboarding_reason.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "base:record:delete", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:pre_hire:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:bp.list:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.social_insurance:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.weekly_working_hours:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:cost_center:read", + "final_score": "67.1507", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.mail_contact.mail_address:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "admin:app.enable:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:user_migration:multi-geo", + "final_score": "44.4430", + "recommend": "false" + }, + { + "scope_name": "im:tag:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.assignment:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:external_offer:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "application:application.app_message_stats.overview:readonly", + "final_score": "71.6425", + "recommend": "false" + }, + { + "scope_name": "hire:exam", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job_grade:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.social_adjust_record:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "base:field:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "drive:file:download", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:leaves:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.notes:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:food_manage_license:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:contract.period:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "im:chat.chat_pins:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.political_affiliation:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.born_country_region:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.compensation_type:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "docs:permission.setting:readonly", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "wiki:wiki:readonly", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "application:application", + "final_score": "52.4430", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_type:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:cost_center:write", + "final_score": "56.0359", + "recommend": "false" + }, + { + "scope_name": "contact:contact:update_department_id", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes:readonly", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "docx:document:create", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:cost_allocation:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.join_date:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "drive:file:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:international_assignment:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.dotted_line_leaders:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "docs:document.comment:create", + "final_score": "72.7705", + "recommend": "true" + }, + { + "scope_name": "moments:moments:readonly", + "final_score": "57.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:person.born_country_region:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.work_shift:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:transform_onboarding_task", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "base:dashboard:copy", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "directory:employee.work.positions:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "payroll:payroll_calculation_item:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:app_feed_card:write", + "final_score": "58.2590", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox", + "final_score": "46.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.job_grade:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.company:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "search:data_source:create", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.event:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "hire:attachment:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "task:task:write", + "final_score": "60.6000", + "recommend": "true" + }, + { + "scope_name": "aily:data_asset:write", + "final_score": "83.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:person.work_experience:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "search:department:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.company_sponsored_visa:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.collab_plugins:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.native_region:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "docs:document.comment:write_only", + "final_score": "50.9638", + "recommend": "true" + }, + { + "scope_name": "corehr:position.job:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.rule:write", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "payroll:external_datasource_record:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:custom_field:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee:read", + "final_score": "55.9638", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes.media:export", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:message:readonly", + "final_score": "70.0000", + "recommend": "true" + }, + { + "scope_name": "base:record:read", + "final_score": "77.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:employee.event:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.employment_custom_field:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat:operate_as_owner", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "directory:employee.base.resign_time:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.phone:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:doc:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet:read", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:person.citizenship_status:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "admin:badge", + "final_score": "54.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.compensation_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:resume:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "vc:meeting.meetingevent:read", + "final_score": "67.4720", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.work_shift:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:read", + "final_score": "59.4720", + "recommend": "false" + }, + { + "scope_name": "base:dashboard:delete", + "final_score": "76.3213", + "recommend": "true" + }, + { + "scope_name": "acs:devices:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "app_engine:apps:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "task:task", + "final_score": "52.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:person.nationality:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.work_place:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "task:task.event_update_tenant:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:agency_account:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_plan:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet", + "final_score": "44.0000", + "recommend": "true" + }, + { + "scope_name": "vc:export", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "slides:presentation:write_only", + "final_score": "50.9638", + "recommend": "true" + }, + { + "scope_name": "directory:employee:list", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.resident_tax:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "spark:app.sql_commands:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "wiki:member:create", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "wiki:space:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "directory:employee.base.description:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "okr:okr", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "wiki:member:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:person.national_id.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.position:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.offboarding_reason:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:place:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "contact:role:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "base:record:update", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "directory:employee.work.resign_reason:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "im:message:send_sys_msg", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "base:field:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:process.detail:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.work_location:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "cardkit:card:write", + "final_score": "54.0918", + "recommend": "false" + }, + { + "scope_name": "space:document:shortcut", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "im:biz_entity_tag_relation:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "docx:document:readonly", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.job:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:background_check_order", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:workforce_plan_centralized_reporting_project_detail:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:person.resident_tax_custom_field:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "helpdesk:all", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "im:chat:readonly", + "final_score": "62.0000", + "recommend": "true" + }, + { + "scope_name": "im:message.group_msg:get_as_user", + "final_score": "66.3213", + "recommend": "true" + }, + { + "scope_name": "search:data_schemas:create", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "baike:entity", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.work_location:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "tenant:tenant.product_assign_info:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_plan_detail.items:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mail:public_mailbox:readonly", + "final_score": "67.1507", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.folder:write", + "final_score": "59.7507", + "recommend": "false" + }, + { + "scope_name": "hire:interview:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "im:message.send_as_user", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "app_engine:workspace.table:write", + "final_score": "64.7705", + "recommend": "false" + }, + { + "scope_name": "contact:user.department_path:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet:create", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:probation:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "spark:app.table.record:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.event_subscriber:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "drive:drive", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "hire:tripartite_agreement:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job_family:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:common_data.preset_data:read", + "final_score": "60.4359", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.remark:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "hire:external_offer", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "task:tasklist:read", + "final_score": "75.6590", + "recommend": "true" + }, + { + "scope_name": "block:message", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.block_list:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "drive:drive.search:readonly", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "contact:user.assign_info:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.event:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:user_migration", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.name.first_name:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:bank_card:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "document_ai:business_license:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "docx:document.block:convert", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "directory:department.status:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "okr:okr.progress:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.block_list:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.assessment:write", + "final_score": "61.0430", + "recommend": "false" + }, + { + "scope_name": "document_ai:taxi_invoice:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:signature_template:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:person.date_of_birth:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.flow_id:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.acl:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "im:message.urgent", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "base:record:create", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "corehr:person.personal_profile:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "hire:talent_folder_association", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_number:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:file:download", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:exchange.bindings:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "base:table:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "board:whiteboard:node:update", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "search:data_schemas:update", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:employee.bp:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "application:application:self_manage", + "final_score": "65.9638", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.work_shift:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "vc:reserve:readonly", + "final_score": "87.8295", + "recommend": "true" + }, + { + "scope_name": "task:attachment:write", + "final_score": "70.7507", + "recommend": "true" + }, + { + "scope_name": "admin:admin_user_stat:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "performance:performance", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "docs:permission.setting", + "final_score": "68.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.work_shift:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "base:workflow:write", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.bt:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.hukou:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mail:event", + "final_score": "56.1705", + "recommend": "false" + }, + { + "scope_name": "docs:document:import", + "final_score": "70.7507", + "recommend": "true" + }, + { + "scope_name": "corehr:person.date_entered_workforce:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "performance:semester_activity:write", + "final_score": "54.7507", + "recommend": "false" + }, + { + "scope_name": "contact:functional_role", + "final_score": "43.6590", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.probation_outcome:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "mdm:legal_entity:readonly", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "contact:user.employee:readonly", + "final_score": "51.4720", + "recommend": "false" + }, + { + "scope_name": "base:view:write_only", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "im:message.p2p_msg:readonly", + "final_score": "73.4359", + "recommend": "true" + }, + { + "scope_name": "directory:department.delete:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "directory:place.status:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "hire:offer_approval_template:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:business_card:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:draft:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:document.content:read", + "final_score": "77.1705", + "recommend": "true" + }, + { + "scope_name": "document_ai:vehicle_invoice:recognize", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:person.entry_leave_time:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.announcement:read", + "final_score": "75.1507", + "recommend": "true" + }, + { + "scope_name": "baike:entity:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.contract_type:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.resign_date:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "hire:talent_folder:readonly", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "performance:metric_lib:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "attendance_machine:device:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "calendar:settings.caldav:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "base:field:create", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "drive:file.like:readonly", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "directory:employee.work.work_station:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:person.religion:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "approval:task", + "final_score": "50.9638", + "recommend": "false" + }, + { + "scope_name": "component:selector", + "final_score": "66.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.weekly_working_hours:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "im:chat.top_notice:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "mdm:vendor:readonly", + "final_score": "52.4359", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.job_grade:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:flow:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "spark:app.table:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "docs:document.subscription:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "base:form:delete", + "final_score": "76.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:probation.submit:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "im:chat.managers:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:person.education:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department.base:read", + "final_score": "58.5853", + "recommend": "true" + }, + { + "scope_name": "hire:site:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.custom_field:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "board:whiteboard:node:delete", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "docs:permission.member", + "final_score": "61.8295", + "recommend": "true" + }, + { + "scope_name": "component:user_profile", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:pathway:write", + "final_score": "58.2590", + "recommend": "false" + }, + { + "scope_name": "acs:access_record:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.compensation_type:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:process.instance:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.has_offer_salary:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.access_event.bot_p2p_chat:read", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "im:chat:moderation:write_only", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "base:dashboard:create", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "document_ai:chinese_passport:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "collab_plugins:collab_plugins", + "final_score": "61.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.job_family:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.working_hours_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "translation:text", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "application:application.app_package", + "final_score": "50.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:contract:create", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "contact:user.job_family:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "collab_plugins:collab_plugins.relation.change:read", + "final_score": "79.8295", + "recommend": "true" + }, + { + "scope_name": "wiki:node:copy", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.custom_org:write", + "final_score": "67.7507", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.status:read", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "vc:record:readonly", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "search:message", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "slides:presentation:create", + "final_score": "74.9213", + "recommend": "true" + }, + { + "scope_name": "contact:user.dotted_line_leader_info.read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "drive:file:view_record:readonly", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation_archive_detail:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.assessment.submit2:write", + "final_score": "67.2425", + "recommend": "false" + }, + { + "scope_name": "passport:session:logout", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "hire:referral:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:position.job_level:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:document.media:download", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation.social_plan:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:employee:read", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:signature_file.pre_hire:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "task:section:read", + "final_score": "80.1507", + "recommend": "true" + }, + { + "scope_name": "aily:message:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:audit_log.openapi_log:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "admin:app.user_usable:readonly", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.education:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes.statistics:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "board:whiteboard:node:create", + "final_score": "67.5638", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.job_level:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "base:app:copy", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "aily:file:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.cost_center:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.pathway:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "base:role:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.checklist_status_message:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.workforce_type:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "im:message", + "final_score": "49.0000", + "recommend": "true" + }, + { + "scope_name": "tenant:tenant.domain:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:complete", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:workspace.table:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "approval:instance", + "final_score": "49.6590", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.folder:read", + "final_score": "71.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.withdrawn_reason:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "space:document:delete", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "base:role:create", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "wiki:space:retrieve", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "vc:meeting", + "final_score": "58.9638", + "recommend": "true" + }, + { + "scope_name": "tenant:tenant:readonly", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "security_and_compliance:user_migration_task", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "mail:public_mailbox", + "final_score": "44.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.work_calendar:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "wiki:node:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "app_engine:dataset.meta:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "docs:permission.member:transfer", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:pre_hire.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:workforce_detail:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.subscription_ids:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.event:reply", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job.only:read", + "final_score": "71.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job_grade:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "wiki:node:move", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:person.marital_status:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.non_compete_covenant:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:common_data.preset_data:write", + "final_score": "52.6000", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.job_family:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "drive:file.meta.sec_label.read_only", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.update_field_message:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.custom_field:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:leave_granting_record:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "document_ai:vehicle_license:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "helpdesk:all:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "vc:record", + "final_score": "66.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation.social_archive:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "admin:app.admin:check", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "report:report", + "final_score": "66.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.update_event_v2:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.base_work:read", + "final_score": "54.6590", + "recommend": "false" + }, + { + "scope_name": "corehr:company:write", + "final_score": "59.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:authorization:read", + "final_score": "65.4430", + "recommend": "false" + }, + { + "scope_name": "im:datasync.feed_card.time_sensitive:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.job_level:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "hire:interviewer:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "aily:data_asset:upload_file", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "contact:user.employee_number:read", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "wiki:node:retrieve", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:international_assignment:write", + "final_score": "64.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:position.job_grade:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "search:data_item:create", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "bitable:app", + "final_score": "44.0000", + "recommend": "true" + }, + { + "scope_name": "directory:department.parent_id:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.job_grade:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person:write", + "final_score": "59.5638", + "recommend": "false" + }, + { + "scope_name": "app_engine:workspace.table.record:write", + "final_score": "61.0430", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar", + "final_score": "44.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.retain_account:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "hire:questionnaire:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "vc:room:readonly", + "final_score": "54.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_start_date:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.chat_pins:write_only", + "final_score": "56.1705", + "recommend": "false" + }, + { + "scope_name": "myai_data:myai_data:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:message.group_msg", + "final_score": "62.1507", + "recommend": "true" + }, + { + "scope_name": "corehr:signature_file:terminate", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.social_security_city:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:position.working_hours_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "base:collaborator:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "ehr:attachment:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "spark:app.table.record:write", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "corehr:position:write", + "final_score": "49.0918", + "recommend": "false" + }, + { + "scope_name": "corehr:person.native_region:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "hire:auth:readonly", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.update:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.assignment_pay_group:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.tabs:write_only", + "final_score": "62.1507", + "recommend": "true" + }, + { + "scope_name": "directory:employee.base.geo:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:group:readonly", + "final_score": "49.7643", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change:create", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "hire:talent_folder", + "final_score": "40.4918", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes.transcript:export", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "bitable:app:readonly", + "final_score": "57.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.job_level:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.job_grade:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "directory:department.name:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:additional_job:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "im:biz_entity_tag_relation:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:message.pins:write_only", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.retain_account:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:department.cost_center_id:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.notes:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "contact:user.subscription_ids:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "acs:access_record:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.work_location:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.recurring_payment:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "task:custom_field:read", + "final_score": "87.8295", + "recommend": "true" + }, + { + "scope_name": "vc:meeting.all_meeting:readonly", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "im:chat.menu_tree:write_only", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "directory:employee.base.department:read", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "approval:external_approval", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.work_calendar:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.score:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:site", + "final_score": "41.6590", + "recommend": "false" + }, + { + "scope_name": "hire:auth", + "final_score": "46.4430", + "recommend": "false" + }, + { + "scope_name": "okr:okr.content:writeonly", + "final_score": "44.0000", + "recommend": "false" + }, + { + "scope_name": "base:collaborator:delete", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.submit:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.phone:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.position:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:permission.setting:write_only", + "final_score": "60.4430", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation.recurring_payment:update", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "hire:job_requirement", + "final_score": "40.4918", + "recommend": "false" + }, + { + "scope_name": "vc:meeting:readonly", + "final_score": "62.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.recruitment_project_id:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "performance:metric:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "im:user_agent:read", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:probation:write", + "final_score": "62.7507", + "recommend": "false" + }, + { + "scope_name": "admin:ent_email_password", + "final_score": "66.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.compensation_type:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "contact:user.employee_id:readonly", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "mail:user_mailbox.mail_contact:write", + "final_score": "69.7705", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.employment_type:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "security_and_compliance:device_record:write", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "corehr:flow.definition:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.role:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "payroll:cost_allocation_plan:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.work_shift:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:site_job_post:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "app_engine:object.meta:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:job_grade:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.leader:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "mail:user_mailbox:readonly", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "im:resource", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "base:field_group:create", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "hire:interview", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet.meta:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:pre_hire:update", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "task:attachment:read", + "final_score": "87.8295", + "recommend": "true" + }, + { + "scope_name": "vc:report:readonly", + "final_score": "77.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation_item:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:corehr", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "contact:functional_role:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "approval:approval", + "final_score": "44.0000", + "recommend": "false" + }, + { + "scope_name": "docs:document.media:upload", + "final_score": "70.7507", + "recommend": "true" + }, + { + "scope_name": "search:memory_graph_tool_call:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "search:data_schemas:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_indicator:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.acl:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.resurrect:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "vc:room", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "space:document.event:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "spark:directory.user.id_convert:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:time_off:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "application:bot.menu:write", + "final_score": "75.4295", + "recommend": "false" + }, + { + "scope_name": "trust_party:collaboration.tenant:readonly", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "task:task:writeonly", + "final_score": "44.0000", + "recommend": "false" + }, + { + "scope_name": "base:collaborator:create", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "im:message.urgent.status:write", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "corehr:person.emergency_contact:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "im:message.reactions:write_only", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.job:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.base:read", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "space:document:retrieve", + "final_score": "44.4430", + "recommend": "false" + }, + { + "scope_name": "corehr:person.gender:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.additional_nationalities:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:write", + "final_score": "52.6000", + "recommend": "false" + }, + { + "scope_name": "admin:admin_dept_stat:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.email:read", + "final_score": "67.1507", + "recommend": "true" + }, + { + "scope_name": "aily:knowledge:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "mail:user_mailbox.message.subject:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:device_apply_record:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "base:view:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.compensation_type:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "baike:entity:exempt_review", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data.work_shift:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:locations:read", + "final_score": "61.4918", + "recommend": "false" + }, + { + "scope_name": "document_ai:contract:field_extract", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "base:role:delete", + "final_score": "71.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:approval_groups.orgdraft_job_change:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:department.manager.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "payroll:payment_activity:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job_level:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "calendar:room", + "final_score": "39.4359", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.signature:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "base:table:update", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.position:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "app_engine:seat_assignments:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.noncompete_agreement:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes.artifacts:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:common_data.id.convert:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_change_reason:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:global_option:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.name.another_name:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data.compensation_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.members:read", + "final_score": "71.9638", + "recommend": "true" + }, + { + "scope_name": "aily:data_asset:read", + "final_score": "76.6425", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.metric:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.position:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.job_level:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.personal_profile:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.members:bot_access", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "directory:department.order_weight:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "aily:run:write", + "final_score": "75.4295", + "recommend": "false" + }, + { + "scope_name": "optical_char_recognition:image", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "im:chat.announcement:write_only", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "base:workflow:update", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "im:message.urgent:phone", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "search:docs:read", + "final_score": "64.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.company_manual_updated:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "task:tasklist:write", + "final_score": "60.6000", + "recommend": "true" + }, + { + "scope_name": "corehr:job:write", + "final_score": "53.3643", + "recommend": "false" + }, + { + "scope_name": "corehr:signature_file:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.function:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee:search", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.create:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:department.operation_log:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:agency_account", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.job:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "hire:offer", + "final_score": "40.4918", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.message.body:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "wiki:setting:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "approval:definition", + "final_score": "54.1507", + "recommend": "false" + }, + { + "scope_name": "base:dashboard:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:department:write", + "final_score": "44.6000", + "recommend": "false" + }, + { + "scope_name": "corehr:locations:write", + "final_score": "52.6000", + "recommend": "false" + }, + { + "scope_name": "docs:permission.member:retrieve", + "final_score": "62.1507", + "recommend": "true" + }, + { + "scope_name": "hire:talent_tag", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "document_ai:tw_mainland_travel_permit:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "im:message.p2p_msg:get_as_user", + "final_score": "66.3213", + "recommend": "true" + }, + { + "scope_name": "attendance:task", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.weekly_working_hours:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "drive:drive.metadata:readonly", + "final_score": "66.5853", + "recommend": "true" + }, + { + "scope_name": "app_engine:object.record:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:employees.international_assignment:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "offline_access", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "search:data_source:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.last_attendance_date:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "base:table:delete", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.cost_center:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "contact:job_title:readonly", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "docs:document.comment:read", + "final_score": "73.4430", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.remark:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "search:data_source:readonly", + "final_score": "57.7643", + "recommend": "false" + }, + { + "scope_name": "contact:contact:update_user_id", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "docs:permission.setting:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "task:task.privilege:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "vc:reserve", + "final_score": "66.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:person.is_old_alone:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "im:chat", + "final_score": "49.0000", + "recommend": "true" + }, + { + "scope_name": "okr:okr.progress.file:upload", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "calendar:exchange.bindings:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.enterprise_email:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "hire:agency", + "final_score": "44.4430", + "recommend": "false" + }, + { + "scope_name": "im:chat:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "attendance_machine:check_in_record:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.bank_account:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.staff_status:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:user.basic_profile:readonly", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "docs:document.subscription", + "final_score": "66.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation_archive_detail.change_description:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "contact:user.job_level:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:user.email:readonly", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:custom_org:write", + "final_score": "56.0359", + "recommend": "false" + }, + { + "scope_name": "contact:user:search", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "payroll:payment_details:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "personal_settings:status:system_status_update", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:person.race:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:permission.member:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:additional_job.service_company:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.dependent:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "drive:file:upload", + "final_score": "70.7507", + "recommend": "true" + }, + { + "scope_name": "docs:permission.member:readonly", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "directory:job_title.base:read", + "final_score": "51.4720", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding:read", + "final_score": "55.9638", + "recommend": "false" + }, + { + "scope_name": "corehr:person.phone.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.log:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "hire:referral_account:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "docs:document.comment:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:workforce_detail:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:person.is_disabled:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "application:application.collaborators:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "application:application.bot.operator_name:readonly", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "search:data_item:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "hire:external_application:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.mail_contact.phone:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mail:mailgroup", + "final_score": "44.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employees.international_assignment:write", + "final_score": "54.7507", + "recommend": "false" + }, + { + "scope_name": "wiki:space:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.custom_field:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:device_apply_record:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "mdm:legal_entity", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "base:app:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:additional_job.job:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding:write", + "final_score": "51.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:employee.all_bp:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:contract:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.lump_sum_payment:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "passport:session_mask:readonly", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "hire:advertisement", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "wiki:wiki", + "final_score": "44.0000", + "recommend": "true" + }, + { + "scope_name": "contact:unit:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.non_compete_covenant:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_level:read", + "final_score": "63.9638", + "recommend": "false" + }, + { + "scope_name": "corehr:security_group:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "admin:app.category:update", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "okr:okr.progress:writeonly", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "hire:agency:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "performance:performance:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "hire:background_check_order:readonly", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.pathway:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.name.name:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.probation_end_date:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.date_entered_workforce:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_start_date:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.extended_probation_period_duration:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.self_review:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "hire:application", + "final_score": "37.5853", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:user_migration:readonly", + "final_score": "71.8295", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.message:readonly", + "final_score": "49.7643", + "recommend": "false" + }, + { + "scope_name": "docs:event.document_edited:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "aily:skill:write", + "final_score": "87.9213", + "recommend": "true" + }, + { + "scope_name": "acs:device:write", + "final_score": "51.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.custom_field:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.work_experience:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:position.direct_leader:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:offer_schema:readonly", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "hire:employee", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "im:chat:create_by_user", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "contact:contact.base:readonly", + "final_score": "70.0000", + "recommend": "true" + }, + { + "scope_name": "okr:okr.period:readonly", + "final_score": "71.6425", + "recommend": "false" + }, + { + "scope_name": "hire:exam:readonly", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "corehr:signature_file.pre_hire:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "serviceaccount:approval:approvals:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.service_company:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "app_engine:dataset.record:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:ehr_import", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "application:application:patch", + "final_score": "54.7507", + "recommend": "false" + }, + { + "scope_name": "vc:note:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "search:data_source:update", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "hire:job.composite_info:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.department:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.avatar:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.service_company:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "aily:knowledge:ask", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:check_work_email", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "payroll:payment_activity_details:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:workspace.table.record:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "contact:user.base:readonly", + "final_score": "72.4720", + "recommend": "true" + }, + { + "scope_name": "directory:job_title:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:workforce_plan:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data.assignment_start_reason:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.custom_org_field:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.preferred_name:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:device_record:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "document_ai:driving_license:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "hire:job_requirement:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "mdm:company.company_bank_account.account:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "hire:interviewer", + "final_score": "58.8295", + "recommend": "false" + }, + { + "scope_name": "performance:semester:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "application:application.feedback.feedback_info", + "final_score": "56.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.workforce_type:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change:write", + "final_score": "51.5638", + "recommend": "false" + }, + { + "scope_name": "event:ip_list", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "helpdesk:helpdesk:access", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.event:update", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "corehr:leave_record:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:application:readonly", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "app_engine:role:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:approval_groups.orgdraft_department_change:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department.custom_field:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "cardkit:template:read", + "final_score": "58.4918", + "recommend": "false" + }, + { + "scope_name": "app_engine:seat_activities:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.nationality:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_item_category:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_level:write", + "final_score": "56.0359", + "recommend": "false" + }, + { + "scope_name": "corehr:job_family:write", + "final_score": "55.0720", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.recurring_payment:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "docs:document.comment:delete", + "final_score": "68.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:signature.file:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "hire:offer:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "acs:users", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "board:whiteboard:node:read", + "final_score": "70.6590", + "recommend": "true" + }, + { + "scope_name": "corehr:company:read", + "final_score": "59.4720", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar:read", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "corehr:contract:write", + "final_score": "64.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:job_level", + "final_score": "54.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:employee.add:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.is_adjust_salary:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.pathway:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job:read", + "final_score": "60.4359", + "recommend": "false" + }, + { + "scope_name": "corehr:person.resident_tax_custom_field:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.mail_contact:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.working_calendar:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "contact:work_city:readonly", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:authorization.bp:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:bp.get_by_department:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "im:message.pins:read", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "task:custom_field:write", + "final_score": "74.0430", + "recommend": "true" + }, + { + "scope_name": "base:field:delete", + "final_score": "71.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:employment:write", + "final_score": "51.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_standards:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.cost_center:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "calendar:timeoff", + "final_score": "61.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation.lump_sum_payment:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "wiki:node:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "calendar:calendar:readonly", + "final_score": "62.0000", + "recommend": "true" + }, + { + "scope_name": "im:message.group_at_msg:readonly", + "final_score": "76.9638", + "recommend": "true" + }, + { + "scope_name": "directory:department:search", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.custom_org_field:write", + "final_score": "75.4295", + "recommend": "false" + }, + { + "scope_name": "hire:talent:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.enterprise_email_alias:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:probation.probation_outcome:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:authorization:write", + "final_score": "62.7507", + "recommend": "false" + }, + { + "scope_name": "corehr:person.date_of_birth:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "approval:approval:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "okr:okr.review:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:out:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "base:form:create", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "corehr:common_data.meta_data:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.address:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.emergency_contact:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "contact:job_level", + "final_score": "46.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.status_update_event:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job_family:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "directory:job_title.status:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:probation.probation_extend_expected_end_date:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.dotted_manager:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "payroll:cost_allocation_details:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_archive_detail.salary_level:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:default_cost_center:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "hire:job_process:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:approval:write", + "final_score": "49.0720", + "recommend": "false" + }, + { + "scope_name": "corehr:person.is_old_alone:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:job", + "final_score": "41.6590", + "recommend": "false" + }, + { + "scope_name": "corehr:person.additional_nationalities:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:read_only", + "final_score": "66.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.job:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.custom_org:read", + "final_score": "79.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.is_resigned:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.service_company:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "report:task:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:withdraw_onboarding", + "final_score": "63.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "hire:employee:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.recurring_payment:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "payroll:pay_groups:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "drive:export:readonly", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "calendar:exchange.bindings:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:event:subscribe", + "final_score": "60.4430", + "recommend": "true" + }, + { + "scope_name": "directory:department.organization:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:contract.period:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data.job_data_reason:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "task:task:read", + "final_score": "74.4918", + "recommend": "true" + }, + { + "scope_name": "directory:department:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "document_ai:train_invoice:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "calendar:calendar.acl:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "task:comment:read", + "final_score": "82.1705", + "recommend": "true" + }, + { + "scope_name": "directory:department.data_source:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:user.id:readonly", + "final_score": "77.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:person.gender:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.event.mail_address:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.self_review:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mdm:spend:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "im:tag:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "im:chat.widgets:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "aily:knowledge:write", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "task:tasklist.privilege:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.department:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "payroll:payment_activity:archive", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "okr:okr.period:writeonly", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "auth:user_access_token:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "hire:todo:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:approval_groups.orgdraft_position_change:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.work_shift:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "ehr:employee:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "aily:message:read", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "im:message.reactions:read", + "final_score": "82.1705", + "recommend": "true" + }, + { + "scope_name": "base:workflow:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "base:table:create", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "search:data_item:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "attendance:rule", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "document_ai:vat_invoice:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "im:chat:read", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:custom_org:read", + "final_score": "67.1507", + "recommend": "false" + }, + { + "scope_name": "im:chat.menu_tree:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "directory:employee_type_enum:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "base:workflow:create", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "vc:alert:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:room:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.custom_field:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "mail:mailgroup:readonly", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data.service_company:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employee:write", + "final_score": "46.1853", + "recommend": "false" + }, + { + "scope_name": "directory:department.update:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "aily:session:write", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "space:folder:create", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.pathway:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "base:workflow:delete", + "final_score": "76.3213", + "recommend": "true" + }, + { + "scope_name": "ea_integration_platform:lawfirm_attorney_capacity:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.members:write_only", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "directory:department.has_child:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_grade:write", + "final_score": "51.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:department.manager:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.department_path:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.compensation_type:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data:read", + "final_score": "71.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:person.legal_name:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "app_engine:attachment:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "docs:event.document_deleted:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "okr:okr:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "im:message.urgent:sms", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.working_hours_type:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.legal_name:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:corehr:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "contact:user.phone:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.subscription_ids:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "calendar:calendar:update", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.name.last_name:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.is_primary_admin:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.check_in_data:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "performance:review_template:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "im:chat:delete", + "final_score": "76.3213", + "recommend": "true" + }, + { + "scope_name": "performance:semester_activity:read", + "final_score": "55.9638", + "recommend": "false" + }, + { + "scope_name": "corehr:person.resident_tax:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:document:export", + "final_score": "69.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:job_data:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:talent_blocklist", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "hire:external_application", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "report:rule:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:approval_groups:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_end_date:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "base:role:update", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "contact:user.user_geo", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.duration_type:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.social_security_city:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.job_number:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "baike:entity:exempt_delete", + "final_score": "55.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_plan_detail.indicators:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:department.custom_fields:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "spark:app.table:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.message.address:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "wiki:member:retrieve", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "calendar:calendar:subscribe", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "aily:run:read", + "final_score": "76.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:restore_flow_instance", + "final_score": "63.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:department.organize:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "speech_to_text:speech", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "calendar:settings.workhour:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.external_id:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "task:comment:readonly", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.custom_field:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "application:application.app_version", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.resign_type:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "verification:verification_information:readonly", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "directory:department.leader:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.direct_manager:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.free_busy:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "attendance:rule:readonly", + "final_score": "49.7643", + "recommend": "false" + }, + { + "scope_name": "hire:referral_website:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "admin:app.admin_id:readonly", + "final_score": "76.6425", + "recommend": "false" + }, + { + "scope_name": "base:dashboard:update", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "corehr:person.passport_number:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "hire:job:readonly", + "final_score": "50.5853", + "recommend": "false" + }, + { + "scope_name": "wiki:setting:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "directory:employee.to_be_resigned:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.background_image:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:default_cost_center:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:questionnaire", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:security.audit_log:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.rule:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:contact", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:process.instance:write", + "final_score": "56.5638", + "recommend": "false" + }, + { + "scope_name": "task:comment", + "final_score": "62.1507", + "recommend": "true" + }, + { + "scope_name": "corehr:leave_grant:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.leader_id:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:additional_job.service_company:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:unit", + "final_score": "46.1507", + "recommend": "false" + }, + { + "scope_name": "base:record:retrieve", + "final_score": "66.6425", + "recommend": "true" + }, + { + "scope_name": "im:chat.widgets:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "directory:department.department_path:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "attendance_machine:users", + "final_score": "50.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:person.bank_account:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "base:history:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "admin:app.info:readonly", + "final_score": "57.0000", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.environment_variable:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "task:section:write", + "final_score": "64.0359", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "hire:note:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_archive_detail.indicators:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.onboarding_address:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:contract.company:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department.external_id:read", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "attendance:overtime_approval:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.citizenship_status:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "search:dataset.docs:delete", + "final_score": "63.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:leave:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "admin:app.visibility", + "final_score": "56.1705", + "recommend": "false" + }, + { + "scope_name": "mdm:spend", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "hire:talent", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "im:message:send_as_bot", + "final_score": "57.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:person.martyr_family:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_family:read", + "final_score": "62.6590", + "recommend": "false" + }, + { + "scope_name": "contact:job_family", + "final_score": "46.1507", + "recommend": "false" + }, + { + "scope_name": "base:app:update", + "final_score": "70.4295", + "recommend": "true" + }, + { + "scope_name": "directory:employee.idconvert:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "drive:drive:version", + "final_score": "62.1507", + "recommend": "true" + }, + { + "scope_name": "corehr:person.hukou:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "contact:user.department:readonly", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "base:form:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "im:message:recall", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.custom_field:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:multi_geo_entity.tenant:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:common_data.meta_data:read", + "final_score": "67.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:position:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "hire:referral", + "final_score": "50.6425", + "recommend": "false" + }, + { + "scope_name": "okr:okr.content:readonly", + "final_score": "60.4359", + "recommend": "false" + }, + { + "scope_name": "search:dataset:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:common_data.basic_data:read", + "final_score": "57.0000", + "recommend": "false" + }, + { + "scope_name": "hire:location:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.working_hours_type:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "personal_settings:status:system_status_operate", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "financial_access_platform:data:write", + "final_score": "54.7507", + "recommend": "false" + }, + { + "scope_name": "corehr:department:read", + "final_score": "51.4720", + "recommend": "false" + }, + { + "scope_name": "hire:referral_account", + "final_score": "44.4430", + "recommend": "false" + }, + { + "scope_name": "base:form:update", + "final_score": "72.7705", + "recommend": "true" + }, + { + "scope_name": "corehr:person.national_id:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "wiki:node:create", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "block:entity", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "docs:event.document_opened:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "workplace:workplace_using_data:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "directory:employee.update:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.mobile:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:pathway:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.pay_group:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.dept_order:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "drive:drive:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "app_engine:data.record.change:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "hire:talent_blocklist:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:workspace.sql_commands:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_level:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "aily:skill:read", + "final_score": "87.8295", + "recommend": "true" + }, + { + "scope_name": "slides:presentation:read", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "trust_party:collaboration_rule:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "space:document:move", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "directory:employee.work.extension_number:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "hire:attachment", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.revoke:write", + "final_score": "66.9213", + "recommend": "false" + } +] diff --git a/internal/registry/scopes.go b/internal/registry/scopes.go new file mode 100644 index 00000000..22678b6f --- /dev/null +++ b/internal/registry/scopes.go @@ -0,0 +1,384 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "sort" + "strings" +) + +// IdentityToAccessToken maps the --identity flag value to the corresponding +// accessTokens value used in from_meta JSON files. Bot identity uses +// tenant_access_token, so "bot" maps to "tenant". +func IdentityToAccessToken(identity string) string { + if identity == "bot" { + return "tenant" + } + return identity +} + +// FilterScopes filters scopes by domain and permission level. +func FilterScopes(allScopes []string, domains []string, permissions []string) []string { + var result []string + for _, scope := range allScopes { + parts := strings.Split(scope, ":") + + if len(domains) > 0 { + if len(parts) == 0 { + continue + } + found := false + for _, d := range domains { + if parts[0] == d { + found = true + break + } + } + if !found { + continue + } + } + + if len(permissions) > 0 { + if len(parts) < 3 { + continue + } + perm := parts[2] + matched := false + for _, p := range permissions { + switch p { + case "read": + if strings.Contains(perm, "read") { + matched = true + } + case "write": + if strings.Contains(perm, "write") { + matched = true + } + case "readonly": + if perm == "readonly" { + matched = true + } + case "writeonly": + if perm == "writeonly" || perm == "write_only" { + matched = true + } + } + } + if !matched { + continue + } + } + + result = append(result, scope) + } + return result +} + +// CollectScopesForProjects collects the recommended scope for each API method +// in the specified from_meta projects. For each method, only the scope with +// the highest priority score is selected. +func CollectScopesForProjects(projects []string, identity string) []string { + priorities := LoadScopePriorities() + scopeSet := make(map[string]bool) + for _, project := range projects { + spec := LoadFromMeta(project) + if spec == nil { + continue + } + resources, ok := spec["resources"].(map[string]interface{}) + if !ok { + continue + } + for _, resSpec := range resources { + resMap, ok := resSpec.(map[string]interface{}) + if !ok { + continue + } + methods, ok := resMap["methods"].(map[string]interface{}) + if !ok { + continue + } + for _, methodSpec := range methods { + methodMap, ok := methodSpec.(map[string]interface{}) + if !ok { + continue + } + if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { + supported := false + for _, t := range tokens { + if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { + supported = true + break + } + } + if !supported { + continue + } + } + scopes, ok := methodMap["scopes"].([]interface{}) + if !ok || len(scopes) == 0 { + continue + } + bestScope := "" + bestScore := -1 + for _, s := range scopes { + str, ok := s.(string) + if !ok { + continue + } + score := DefaultScopeScore + if v, exists := priorities[str]; exists { + score = v + } + if score > bestScore { + bestScore = score + bestScope = str + } + } + if bestScope != "" { + scopeSet[bestScope] = true + } + } + } + } + + result := make([]string, 0, len(scopeSet)) + for s := range scopeSet { + result = append(result, s) + } + sort.Strings(result) + return result +} + +// ScopeSource tracks which APIs and shortcuts contributed a scope. +type ScopeSource struct { + APIs []string // e.g. "POST calendar.event.create" + Shortcuts []string // e.g. "+send", "+reply" +} + +// CollectScopesWithSources is like CollectScopesForProjects but also records +// which API method contributed each scope. Used by scope-audit. +func CollectScopesWithSources(projects []string, identity string) ([]string, map[string]*ScopeSource) { + priorities := LoadScopePriorities() + scopeSet := make(map[string]bool) + sources := make(map[string]*ScopeSource) + + for _, project := range projects { + spec := LoadFromMeta(project) + if spec == nil { + continue + } + resources, ok := spec["resources"].(map[string]interface{}) + if !ok { + continue + } + for resName, resSpec := range resources { + resMap, ok := resSpec.(map[string]interface{}) + if !ok { + continue + } + methods, ok := resMap["methods"].(map[string]interface{}) + if !ok { + continue + } + for methodName, methodSpec := range methods { + methodMap, ok := methodSpec.(map[string]interface{}) + if !ok { + continue + } + if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { + supported := false + for _, t := range tokens { + if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { + supported = true + break + } + } + if !supported { + continue + } + } + scopes, ok := methodMap["scopes"].([]interface{}) + if !ok || len(scopes) == 0 { + continue + } + bestScope := "" + bestScore := -1 + for _, s := range scopes { + str, ok := s.(string) + if !ok { + continue + } + score := DefaultScopeScore + if v, exists := priorities[str]; exists { + score = v + } + if score > bestScore { + bestScore = score + bestScope = str + } + } + if bestScope != "" { + scopeSet[bestScope] = true + if sources[bestScope] == nil { + sources[bestScope] = &ScopeSource{} + } + methodID := GetStrFromMap(methodMap, "id") + if methodID == "" { + methodID = project + "." + resName + "." + methodName + } + httpMethod := GetStrFromMap(methodMap, "httpMethod") + if httpMethod == "" { + httpMethod = "?" + } + sources[bestScope].APIs = append(sources[bestScope].APIs, httpMethod+" "+methodID) + } + } + } + } + + // Sort API lists for stable output + for _, src := range sources { + sort.Strings(src.APIs) + } + + result := make([]string, 0, len(scopeSet)) + for s := range scopeSet { + result = append(result, s) + } + sort.Strings(result) + return result, sources +} + +// CommandEntry represents a CLI command (API method or shortcut) and its scopes. +type CommandEntry struct { + Command string // CLI label, e.g. "calendars create" or "+agenda" + Type string // "api" or "shortcut" + Scopes []string // effective scopes (requiredScopes if present, else [bestScope]) + HTTPMethod string // e.g. "POST" (API only) +} + +// CollectCommandScopes walks from_meta methods for the given projects and +// returns one CommandEntry per API method, sorted by command label. +// +// Scope selection per method: +// - If the method has a "requiredScopes" field, all of those scopes are needed (conjunction). +// - Otherwise, only the highest-priority scope from "scopes" is shown (minimum privilege). +func CollectCommandScopes(projects []string, identity string) []CommandEntry { + priorities := LoadScopePriorities() + var entries []CommandEntry + + for _, project := range projects { + spec := LoadFromMeta(project) + if spec == nil { + continue + } + resources, ok := spec["resources"].(map[string]interface{}) + if !ok { + continue + } + for resName, resSpec := range resources { + resMap, ok := resSpec.(map[string]interface{}) + if !ok { + continue + } + methods, ok := resMap["methods"].(map[string]interface{}) + if !ok { + continue + } + for methodName, methodSpec := range methods { + methodMap, ok := methodSpec.(map[string]interface{}) + if !ok { + continue + } + if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { + supported := false + for _, t := range tokens { + if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { + supported = true + break + } + } + if !supported { + continue + } + } + rawScopes, ok := methodMap["scopes"].([]interface{}) + if !ok || len(rawScopes) == 0 { + continue + } + + // Check for requiredScopes (conjunction — all needed) + var effectiveScopes []string + if reqRaw, ok := methodMap["requiredScopes"].([]interface{}); ok && len(reqRaw) > 0 { + for _, s := range reqRaw { + if str, ok := s.(string); ok { + effectiveScopes = append(effectiveScopes, str) + } + } + } else { + // Pick the single best scope (minimum privilege) + bestScope := "" + bestScore := -1 + for _, s := range rawScopes { + str, ok := s.(string) + if !ok { + continue + } + score := DefaultScopeScore + if v, exists := priorities[str]; exists { + score = v + } + if score > bestScore { + bestScore = score + bestScope = str + } + } + if bestScope != "" { + effectiveScopes = []string{bestScope} + } + } + if len(effectiveScopes) == 0 { + continue + } + + httpMethod := GetStrFromMap(methodMap, "httpMethod") + entries = append(entries, CommandEntry{ + Command: resName + " " + methodName, + Type: "api", + Scopes: effectiveScopes, + HTTPMethod: httpMethod, + }) + } + } + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Command < entries[j].Command + }) + return entries +} + +// GetScopesForDomains returns scopes for specific projects (by project name). +func GetScopesForDomains(projects []string, identity string) []string { + return CollectScopesForProjects(projects, identity) +} + +// GetReadOnlyScopes returns read-only scopes from the recommended (best-per-method) scope set. +func GetReadOnlyScopes(identity string) []string { + allProjects := ListFromMetaProjects() + return FilterScopes(CollectScopesForProjects(allProjects, identity), nil, []string{"read", "readonly"}) +} + +// ResolveScopesFromFilters resolves scopes from project and permission filters. +func ResolveScopesFromFilters(projects []string, permissions []string, identity string) []string { + return FilterScopes(CollectScopesForProjects(projects, identity), nil, permissions) +} + +// ComputeMinimumScopeSet computes the minimum set of scopes that covers all +// from_meta API methods. Equivalent to CollectScopesForProjects with all projects. +func ComputeMinimumScopeSet(identity string) []string { + return CollectScopesForProjects(ListFromMetaProjects(), identity) +} diff --git a/internal/registry/service_desc.go b/internal/registry/service_desc.go new file mode 100644 index 00000000..ae644bf5 --- /dev/null +++ b/internal/registry/service_desc.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + _ "embed" + "encoding/json" +) + +//go:embed service_descriptions.json +var serviceDescJSON []byte + +// serviceDescLocale holds title and description for one language. +type serviceDescLocale struct { + Title string `json:"title"` + Description string `json:"description"` +} + +// serviceDescEntry holds bilingual descriptions for a service domain. +type serviceDescEntry struct { + En serviceDescLocale `json:"en"` + Zh serviceDescLocale `json:"zh"` +} + +var serviceDescMap map[string]serviceDescEntry + +func loadServiceDescriptions() map[string]serviceDescEntry { + if serviceDescMap != nil { + return serviceDescMap + } + serviceDescMap = make(map[string]serviceDescEntry) + _ = json.Unmarshal(serviceDescJSON, &serviceDescMap) + return serviceDescMap +} + +func getServiceLocale(name, lang string) *serviceDescLocale { + m := loadServiceDescriptions() + entry, ok := m[name] + if !ok { + return nil + } + if lang == "en" { + return &entry.En + } + return &entry.Zh +} + +// GetServiceDescription returns the localized description for a service domain, +// suitable for --help output. Returns the description field directly. +// Returns empty string if not found in the config. +func GetServiceDescription(name, lang string) string { + loc := getServiceLocale(name, lang) + if loc == nil { + return "" + } + return loc.Description +} + +// GetServiceTitle returns the localized title for a service domain. +// Returns empty string if not found. +func GetServiceTitle(name, lang string) string { + loc := getServiceLocale(name, lang) + if loc == nil { + return "" + } + return loc.Title +} + +// GetServiceDetailDescription returns the localized detail description for a service domain. +// Returns empty string if not found. +func GetServiceDetailDescription(name, lang string) string { + loc := getServiceLocale(name, lang) + if loc == nil { + return "" + } + return loc.Description +} diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json new file mode 100644 index 00000000..2ac898b8 --- /dev/null +++ b/internal/registry/service_descriptions.json @@ -0,0 +1,58 @@ +{ + "base": { + "en": { "title": "Base", "description": "Table, field, record, and view management" }, + "zh": { "title": "多维表格", "description": "数据表、字段、记录、视图" } + }, + "calendar": { + "en": { "title": "Calendar", "description": "Calendar, event, and attendee management" }, + "zh": { "title": "日历", "description": "日程、日历、参会人管理" } + }, + "contact": { + "en": { "title": "Contacts", "description": "Contacts operations" }, + "zh": { "title": "通讯录", "description": "用户查询、通讯录搜索" } + }, + "docs": { + "en": { "title": "Docs", "description": "Document and content operations" }, + "zh": { "title": "文档", "description": "文档创建、编辑、搜索" } + }, + "drive": { + "en": { "title": "Drive", "description": "File, comment, permission, and upload management" }, + "zh": { "title": "云空间", "description": "文件管理、文档评论、素材上传下载、文档权限管理" } + }, + "event": { + "en": { "title": "Event", "description": "Event subscription management" }, + "zh": { "title": "事件订阅", "description": "WebSocket 实时推送" } + }, + "im": { + "en": { "title": "Messenger", "description": "Message and group chat management" }, + "zh": { "title": "消息与群组", "description": "消息发送、群聊管理" } + }, + "mail": { + "en": { "title": "Mail", "description": "Email, draft, folder, and contacts management" }, + "zh": { "title": "邮箱", "description": "查看和管理用户邮箱数据,包括邮件、草稿、文件夹和联系人" } + }, + "minutes": { + "en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" }, + "zh": { "title": "妙记", "description": "妙记信息获取、内容查询" } + }, + "sheets": { + "en": { "title": "Sheets", "description": "Spreadsheet operations" }, + "zh": { "title": "电子表格", "description": "电子表格操作" } + }, + "task": { + "en": { "title": "Task", "description": "Task, task list, and subtask management" }, + "zh": { "title": "任务", "description": "任务、清单、子任务管理" } + }, + "vc": { + "en": { "title": "VC", "description": "Video conference and meeting note management" }, + "zh": { "title": "视频会议", "description": "视频会议与会议纪要管理" } + }, + "whiteboard": { + "en": { "title": "Whiteboard", "description": "Create and edit boards" }, + "zh": { "title": "画板", "description": "画板创建、编辑" } + }, + "wiki": { + "en": { "title": "Wiki", "description": "Wiki space and node management" }, + "zh": { "title": "知识库", "description": "知识空间、节点管理" } + } +} diff --git a/internal/util/json.go b/internal/util/json.go new file mode 100644 index 00000000..8543cbf6 --- /dev/null +++ b/internal/util/json.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +import ( + "encoding/json" +) + +// ToFloat64 extracts a float64 from a value that may be float64 or json.Number. +// Returns (0, false) if the value is neither. +func ToFloat64(v interface{}) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case json.Number: + f, err := n.Float64() + return f, err == nil + case int: + return float64(n), true + case int64: + return float64(n), true + } + return 0, false +} diff --git a/internal/util/reflect.go b/internal/util/reflect.go new file mode 100644 index 00000000..5589dfe9 --- /dev/null +++ b/internal/util/reflect.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +import "reflect" + +// IsNil reports whether v is nil, covering both untyped nil (interface itself) +// and typed nil (e.g. (*T)(nil) wrapped in interface{}). +// Avoids direct interface{} == nil comparison . +func IsNil(v interface{}) bool { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return true + } + switch rv.Kind() { + case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Func, reflect.Interface, reflect.Chan: + return rv.IsNil() + default: + return false + } +} + +// IsEmptyValue checks whether v is considered empty using reflect. +// Returns true for nil interface, and zero values of the underlying type +// (e.g. "", 0, false, empty slice/map). +func IsEmptyValue(v interface{}) bool { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return true + } + return rv.IsZero() +} diff --git a/internal/util/reflect_test.go b/internal/util/reflect_test.go new file mode 100644 index 00000000..16827443 --- /dev/null +++ b/internal/util/reflect_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +import "testing" + +func TestIsNil(t *testing.T) { + var nilPtr *int + var nilSlice []int + var nilMap map[string]int + var nilChan chan int + var nilFunc func() + nonNilPtr := new(int) + + tests := []struct { + name string + v interface{} + want bool + }{ + {"nil", nil, true}, + {"empty string", "", false}, + {"zero int", 0, false}, + {"false", false, false}, + {"non-nil map", map[string]interface{}{}, false}, + {"non-nil slice", []interface{}{}, false}, + {"string value", "hello", false}, + {"typed-nil pointer", nilPtr, true}, + {"typed-nil slice", nilSlice, true}, + {"typed-nil map", nilMap, true}, + {"typed-nil chan", nilChan, true}, + {"typed-nil func", nilFunc, true}, + {"non-nil pointer", nonNilPtr, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsNil(tt.v); got != tt.want { + t.Errorf("IsNil(%v) = %v, want %v", tt.v, got, tt.want) + } + }) + } +} + +func TestIsEmptyValue(t *testing.T) { + tests := []struct { + name string + v interface{} + want bool + }{ + {"nil", nil, true}, + {"empty string", "", true}, + {"non-empty string", "hello", false}, + {"zero int", 0, true}, + {"non-zero int", 42, false}, + {"zero float64", float64(0), true}, + {"non-zero float64", float64(3.14), false}, + {"false", false, true}, + {"true", true, false}, + {"nil slice", []interface{}(nil), true}, + {"empty slice", []interface{}{}, false}, + {"non-empty slice", []interface{}{1}, false}, + {"nil map", map[string]interface{}(nil), true}, + {"empty map", map[string]interface{}{}, false}, + {"non-empty map", map[string]interface{}{"a": 1}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsEmptyValue(tt.v); got != tt.want { + t.Errorf("IsEmptyValue(%v) = %v, want %v", tt.v, got, tt.want) + } + }) + } +} diff --git a/internal/util/strings.go b/internal/util/strings.go new file mode 100644 index 00000000..0850a607 --- /dev/null +++ b/internal/util/strings.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +// TruncateStr truncates s to at most n runes, safe for multi-byte (e.g. CJK) characters. +func TruncateStr(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +// TruncateStrWithEllipsis truncates s to at most n runes (including "..." suffix). +func TruncateStrWithEllipsis(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + if n < 3 { + return string(r[:n]) + } + return string(r[:n-3]) + "..." +} diff --git a/internal/validate/atomicwrite.go b/internal/validate/atomicwrite.go new file mode 100644 index 00000000..8bd5b471 --- /dev/null +++ b/internal/validate/atomicwrite.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// AtomicWrite writes data to path atomically by creating a temp file in the +// same directory, writing and fsyncing the data, then renaming over the target. +// It replaces os.WriteFile for all config and download file writes. +// +// os.WriteFile truncates the target before writing, so a process kill (CI timeout, +// OOM, Ctrl+C) between truncate and completion leaves the file empty or partial. +// AtomicWrite avoids this: on any failure the temp file is cleaned up and the +// original file remains untouched. +func AtomicWrite(path string, data []byte, perm os.FileMode) error { + return atomicWrite(path, perm, func(tmp *os.File) error { + _, err := tmp.Write(data) + return err + }) +} + +// AtomicWriteFromReader atomically copies reader contents into path. +func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) { + var copied int64 + err := atomicWrite(path, perm, func(tmp *os.File) error { + n, err := io.Copy(tmp, reader) + copied = n + return err + }) + if err != nil { + return 0, err + } + return copied, nil +} + +func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + + success := false + defer func() { + if !success { + tmp.Close() + os.Remove(tmpName) + } + }() + + if err := tmp.Chmod(perm); err != nil { + return err + } + if err := writeFn(tmp); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + success = true + return nil +} diff --git a/internal/validate/atomicwrite_test.go b/internal/validate/atomicwrite_test.go new file mode 100644 index 00000000..b4e328b0 --- /dev/null +++ b/internal/validate/atomicwrite_test.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "os" + "path/filepath" + "runtime" + "sync" + "testing" +) + +func TestAtomicWrite_WritesContentAndPermissionCorrectly(t *testing.T) { + // GIVEN: a target path in a temp directory + dir := t.TempDir() + path := filepath.Join(dir, "test.json") + data := []byte(`{"key":"value"}`) + + // WHEN: AtomicWrite writes data with 0644 permission + if err := AtomicWrite(path, data, 0644); err != nil { + t.Fatalf("AtomicWrite failed: %v", err) + } + + // THEN: file content matches exactly + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(got) != string(data) { + t.Errorf("content = %q, want %q", got, data) + } +} + +func TestAtomicWrite_SetsRestrictivePermission(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission test not reliable on Windows") + } + + // GIVEN: a target path + dir := t.TempDir() + path := filepath.Join(dir, "secret.json") + + // WHEN: AtomicWrite writes with 0600 permission + if err := AtomicWrite(path, []byte("secret"), 0600); err != nil { + t.Fatalf("AtomicWrite failed: %v", err) + } + + // THEN: file permission is exactly 0600 (owner read-write only) + info, _ := os.Stat(path) + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("permission = %04o, want 0600", perm) + } +} + +func TestAtomicWrite_OverwritesExistingFile(t *testing.T) { + // GIVEN: an existing file with old content + dir := t.TempDir() + path := filepath.Join(dir, "test.json") + AtomicWrite(path, []byte("old"), 0644) + + // WHEN: AtomicWrite overwrites with new content + if err := AtomicWrite(path, []byte("new"), 0644); err != nil { + t.Fatalf("second write failed: %v", err) + } + + // THEN: file contains new content + got, _ := os.ReadFile(path) + if string(got) != "new" { + t.Errorf("content = %q, want %q", got, "new") + } +} + +func TestAtomicWrite_LeavesNoResidualTempFileOnError(t *testing.T) { + // GIVEN: a target path in a non-existent nested directory + path := filepath.Join(t.TempDir(), "nonexistent", "subdir", "file.txt") + + // WHEN: AtomicWrite fails (parent directory doesn't exist) + err := AtomicWrite(path, []byte("data"), 0644) + + // THEN: the write fails + if err == nil { + t.Fatal("expected error writing to nonexistent dir") + } + + // THEN: no .tmp files are left behind + parentDir := filepath.Dir(filepath.Dir(path)) + entries, _ := os.ReadDir(parentDir) + for _, e := range entries { + if filepath.Ext(e.Name()) == ".tmp" { + t.Errorf("residual temp file found: %s", e.Name()) + } + } +} + +func TestAtomicWrite_PreservesOriginalFileOnFailure(t *testing.T) { + // GIVEN: an existing file with known content + dir := t.TempDir() + original := []byte("original content") + path := filepath.Join(dir, "file.json") + if err := AtomicWrite(path, original, 0644); err != nil { + t.Fatal(err) + } + + // WHEN: AtomicWrite targets a non-existent directory (guaranteed to fail even as root) + badPath := filepath.Join(dir, "no", "such", "dir", "file.json") + err := AtomicWrite(badPath, []byte("new"), 0644) + + // THEN: write fails + if err == nil { + t.Fatal("expected error writing to non-existent dir") + } + + // THEN: the original file at the valid path is untouched + got, _ := os.ReadFile(path) + if string(got) != string(original) { + t.Errorf("original file corrupted: got %q, want %q", got, original) + } +} + +func TestAtomicWrite_HandlesCorrectlyUnderConcurrentWrites(t *testing.T) { + // GIVEN: a target file that will be written by 20 concurrent goroutines + dir := t.TempDir() + path := filepath.Join(dir, "concurrent.json") + + // WHEN: 20 goroutines write simultaneously + var wg sync.WaitGroup + for i := range 20 { + wg.Add(1) + go func(n int) { + defer wg.Done() + data := []byte(`{"n":` + string(rune('0'+n%10)) + `}`) + AtomicWrite(path, data, 0644) + }(i) + } + wg.Wait() + + // THEN: file exists and is valid (not corrupted by interleaved writes) + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if len(got) == 0 { + t.Error("file is empty after concurrent writes") + } +} diff --git a/internal/validate/input.go b/internal/validate/input.go new file mode 100644 index 00000000..0213fa67 --- /dev/null +++ b/internal/validate/input.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "fmt" + "strings" +) + +// RejectControlChars rejects C0 control characters (except \t and \n) and +// dangerous Unicode characters from user input. +// +// Control characters cause subtle security issues: null bytes truncate strings +// at the C layer, \r\n enables HTTP header injection +// Unicode characters allow visual spoofing (e.g. making "report.exe" display +// as "report.txt"). +func RejectControlChars(value, flagName string) error { + for _, r := range value { + if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) { + return fmt.Errorf("%s contains invalid control characters", flagName) + } + if isDangerousUnicode(r) { + return fmt.Errorf("%s contains dangerous Unicode characters", flagName) + } + } + return nil +} + +// RejectCRLF rejects strings containing carriage return (\r) or line feed (\n). +// These characters enable MIME/HTTP header injection and must never appear in +// header field names, values, Content-ID, or filename parameters. +func RejectCRLF(value, fieldName string) error { + if strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("%s contains invalid line break characters", fieldName) + } + return nil +} + +// StripQueryFragment removes any ?query or #fragment suffix from a URL path. +// API parameters must go through structured --params flags, not embedded in +// the path, to prevent parameter injection and behaviour confusion. +func StripQueryFragment(path string) string { + for i := 0; i < len(path); i++ { + if path[i] == '?' || path[i] == '#' { + return path[:i] + } + } + return path +} + +// isDangerousUnicode identifies Unicode code points used for visual spoofing attacks. +// These characters are invisible or alter text direction, allowing attackers to make +// "report.exe" display as "report.txt" (Bidi override) or insert hidden content +// (zero-width characters). +func isDangerousUnicode(r rune) bool { + switch { + case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner + return true + case r == 0xFEFF: // BOM / ZWNBSP + return true + case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO + return true + case r >= 0x2028 && r <= 0x2029: // line/paragraph separator + return true + case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI + return true + } + return false +} diff --git a/internal/validate/input_test.go b/internal/validate/input_test.go new file mode 100644 index 00000000..cb08087f --- /dev/null +++ b/internal/validate/input_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "testing" +) + +func TestRejectControlChars_FiltersControlCharsAndDangerousUnicode(t *testing.T) { + for _, tt := range []struct { + name string + input string + wantErr bool + }{ + // ── GIVEN: normal text → THEN: allowed ── + {"plain text", "hello world", false}, + {"with tab", "hello\tworld", false}, + {"with newline", "hello\nworld", false}, + {"unicode text", "你好世界", false}, + {"with symbols", "hello!@#$^&*()", false}, + {"empty", "", false}, + + // ── GIVEN: C0 control characters → THEN: rejected ── + {"null byte", "hello\x00world", true}, + {"bell", "hello\x07world", true}, + {"backspace", "hello\x08world", true}, + {"escape", "hello\x1bworld", true}, + {"carriage return", "hello\rworld", true}, + {"form feed", "hello\x0cworld", true}, + {"vertical tab", "hello\x0bworld", true}, + {"DEL", "hello\x7fworld", true}, + + // ── GIVEN: dangerous Unicode characters → THEN: rejected ── + {"zero width space", "hello\u200Bworld", true}, + {"zero width non-joiner", "hello\u200Cworld", true}, + {"zero width joiner", "hello\u200Dworld", true}, + {"BOM", "hello\uFEFFworld", true}, + {"bidi LRE", "hello\u202Aworld", true}, + {"bidi RLE", "hello\u202Bworld", true}, + {"bidi PDF", "hello\u202Cworld", true}, + {"bidi LRO", "hello\u202Dworld", true}, + {"bidi RLO", "hello\u202Eworld", true}, + {"line separator", "hello\u2028world", true}, + {"paragraph separator", "hello\u2029world", true}, + {"bidi LRI", "hello\u2066world", true}, + {"bidi RLI", "hello\u2067world", true}, + {"bidi FSI", "hello\u2068world", true}, + {"bidi PDI", "hello\u2069world", true}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN: RejectControlChars validates the input + err := RejectControlChars(tt.input, "--test") + + // THEN: error matches expectation + if (err != nil) != tt.wantErr { + t.Errorf("RejectControlChars(%q) error = %v, wantErr %v", + tt.input, err, tt.wantErr) + } + }) + } +} + +func TestStripQueryFragment(t *testing.T) { + for _, tt := range []struct { + name string + in string + want string + }{ + {"no query or fragment", "/open-apis/test", "/open-apis/test"}, + {"query only", "/open-apis/test?admin=true", "/open-apis/test"}, + {"fragment only", "/open-apis/test#section", "/open-apis/test"}, + {"query and fragment", "/open-apis/test?a=1#frag", "/open-apis/test"}, + {"empty string", "", ""}, + {"query at start", "?foo=bar", ""}, + {"fragment at start", "#frag", ""}, + } { + t.Run(tt.name, func(t *testing.T) { + got := StripQueryFragment(tt.in) + if got != tt.want { + t.Errorf("StripQueryFragment(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/validate/path.go b/internal/validate/path.go new file mode 100644 index 00000000..f9974cf8 --- /dev/null +++ b/internal/validate/path.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// SafeOutputPath validates a download/export target path for --output flags. +// It rejects absolute paths, resolves symlinks to their real location, and +// verifies the canonical result is still under the current working directory. +// This prevents an AI Agent from being tricked into writing files outside the +// working directory (e.g. "../../.ssh/authorized_keys") or following symlinks +// to sensitive locations. +// +// The returned absolute path MUST be used for all subsequent I/O to prevent +// time-of-check-to-time-of-use (TOCTOU) race conditions. +func SafeOutputPath(path string) (string, error) { + return safePath(path, "--output") +} + +// SafeInputPath validates an upload/read source path for --file flags. +// It applies the same rules as SafeOutputPath — rejecting absolute paths, +// resolving symlinks, and enforcing working directory containment — to prevent an AI Agent +// from being tricked into reading sensitive files like /etc/passwd. +func SafeInputPath(path string) (string, error) { + return safePath(path, "--file") +} + +// SafeLocalFlagPath validates a flag value as a local file path. +// Empty values and http/https URLs are returned unchanged without validation, +// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream. +// For all other values, SafeInputPath rules apply. +// The original relative path is returned unchanged (not resolved to absolute) so +// upload helpers can re-validate at the actual I/O point via SafeUploadPath. +func SafeLocalFlagPath(flagName, value string) (string, error) { + if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { + return value, nil + } + if _, err := SafeInputPath(value); err != nil { + return "", fmt.Errorf("%s: %v", flagName, err) + } + return value, nil +} + +// safePath is the shared implementation for SafeOutputPath and SafeInputPath. +func safePath(raw, flagName string) (string, error) { + if err := RejectControlChars(raw, flagName); err != nil { + return "", err + } + + path := filepath.Clean(raw) + + if filepath.IsAbs(path) { + return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw) + } + + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("cannot determine working directory: %w", err) + } + resolved := filepath.Join(cwd, path) + + // Resolve symlinks: for existing paths, follow to real location; + // for non-existing paths, walk up to the nearest existing ancestor, + // resolve its symlinks, and re-attach the remaining tail segments. + // This prevents TOCTOU attacks where a non-existent intermediate + // directory is replaced with a symlink between check and use. + if _, err := os.Lstat(resolved); err == nil { + resolved, err = filepath.EvalSymlinks(resolved) + if err != nil { + return "", fmt.Errorf("cannot resolve symlinks: %w", err) + } + } else { + resolved, err = resolveNearestAncestor(resolved) + if err != nil { + return "", fmt.Errorf("cannot resolve symlinks: %w", err) + } + } + + canonicalCwd, _ := filepath.EvalSymlinks(cwd) + if !isUnderDir(resolved, canonicalCwd) { + return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw) + } + + return resolved, nil +} + +// resolveNearestAncestor walks up from path until it finds an existing +// ancestor, resolves that ancestor's symlinks, and re-joins the tail. +// This ensures even deeply nested non-existent paths are anchored to a +// real filesystem location, closing the TOCTOU symlink gap. +func resolveNearestAncestor(path string) (string, error) { + var tail []string + cur := path + for { + if _, err := os.Lstat(cur); err == nil { + real, err := filepath.EvalSymlinks(cur) + if err != nil { + return "", err + } + parts := append([]string{real}, tail...) + return filepath.Join(parts...), nil + } + parent := filepath.Dir(cur) + if parent == cur { + // Reached filesystem root without finding an existing ancestor; + // return path as-is and let the containment check reject it. + parts := append([]string{cur}, tail...) + return filepath.Join(parts...), nil + } + tail = append([]string{filepath.Base(cur)}, tail...) + cur = parent + } +} + +// isUnderDir checks whether child is under parent directory. +func isUnderDir(child, parent string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." +} diff --git a/internal/validate/path_test.go b/internal/validate/path_test.go new file mode 100644 index 00000000..bc6b1f48 --- /dev/null +++ b/internal/validate/path_test.go @@ -0,0 +1,285 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) { + for _, tt := range []struct { + name string + input string + wantErr bool + }{ + // ── GIVEN: normal relative paths → THEN: allowed ── + {"normal file", "report.xlsx", false}, + {"subdir file", "output/report.xlsx", false}, + {"current dir explicit", "./file.txt", false}, + {"nested subdir", "a/b/c/file.txt", false}, + {"dot in name", "my.report.v2.xlsx", false}, + {"space in name", "my file.txt", false}, + {"unicode normal", "报告.xlsx", false}, + {"dot-dot resolves to cwd", "subdir/..", false}, + + // ── GIVEN: path traversal via .. → THEN: rejected ── + {"dot-dot escape", "../../.ssh/authorized_keys", true}, + {"dot-dot mid path", "subdir/../../etc/passwd", true}, + {"triple dot-dot", "../../../etc/shadow", true}, + + // ── GIVEN: absolute paths → THEN: rejected ── + {"absolute path unix", "/etc/passwd", true}, + {"absolute path root", "/tmp/evil", true}, + + // ── GIVEN: control characters in path → THEN: rejected ── + {"null byte", "file\x00.txt", true}, + {"carriage return", "file\r.txt", true}, + {"bell char", "file\x07.txt", true}, + + // ── GIVEN: dangerous Unicode in path → THEN: rejected ── + {"bidi RLO", "file\u202Ename.txt", true}, + {"zero width space", "file\u200Bname.txt", true}, + {"BOM char", "file\uFEFFname.txt", true}, + {"line separator", "file\u2028name.txt", true}, + {"bidi LRI", "file\u2066name.txt", true}, + + // ── GIVEN: looks dangerous but is actually safe → THEN: allowed ── + {"literal percent 2e", "%2e%2e/etc/passwd", false}, + {"tilde path", "~/file.txt", false}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN: SafeOutputPath validates the path + _, err := SafeOutputPath(tt.input) + + // THEN: error matches expectation + if (err != nil) != tt.wantErr { + t.Errorf("SafeOutputPath(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + }) + } +} + +func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) { + // GIVEN: a clean temp directory as CWD + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + + // WHEN: SafeOutputPath validates a relative path + got, err := SafeOutputPath("output/file.txt") + + // THEN: returns the canonical absolute path for subsequent I/O + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := filepath.Join(dir, "output", "file.txt") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSafeOutputPath_RejectsSymlinkEscapingCWD(t *testing.T) { + // GIVEN: a symlink in CWD pointing to /etc (outside CWD) + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + os.Symlink("/etc", filepath.Join(dir, "link-to-etc")) + + // WHEN: SafeOutputPath validates a path through the symlink + _, err := SafeOutputPath("link-to-etc/passwd") + + // THEN: rejected because the resolved path is outside CWD + if err == nil { + t.Error("expected error for symlink escaping CWD, got nil") + } +} + +func TestSafeOutputPath_AllowsSymlinkWithinCWD(t *testing.T) { + // GIVEN: a symlink in CWD pointing to a subdirectory within CWD + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + os.MkdirAll(filepath.Join(dir, "real"), 0755) + os.Symlink(filepath.Join(dir, "real"), filepath.Join(dir, "link")) + + // WHEN: SafeOutputPath validates a path through the internal symlink + got, err := SafeOutputPath("link/file.txt") + + // THEN: allowed, resolved to the real path within CWD + if err != nil { + t.Fatalf("symlink within CWD should be allowed: %v", err) + } + want := filepath.Join(dir, "real", "file.txt") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSafeOutputPath_ResolvesAncestorSymlinkWhenParentMissing(t *testing.T) { + // GIVEN: CWD contains a symlink "escape" → /etc, and the target path + // goes through "escape/sub/file.txt" where "sub" does not exist. + // The old code failed to resolve the symlink because the immediate + // parent ("escape/sub") didn't exist, leaving resolved un-anchored. + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + os.Symlink("/etc", filepath.Join(dir, "escape")) + + // WHEN: SafeOutputPath validates a path through the symlink with missing intermediate dirs + _, err := SafeOutputPath("escape/nonexistent/file.txt") + + // THEN: rejected — the resolved path is under /etc, outside CWD + if err == nil { + t.Error("expected error for symlink escaping CWD via non-existent parent, got nil") + } +} + +func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) { + // GIVEN: a deeply nested non-existent path with no symlinks + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + + // WHEN: SafeOutputPath validates "a/b/c/d/file.txt" (none of a/b/c/d exist) + got, err := SafeOutputPath("a/b/c/d/file.txt") + + // THEN: allowed, resolved to canonical path under CWD + if err != nil { + t.Fatalf("deep non-existent path within CWD should be allowed: %v", err) + } + want := filepath.Join(dir, "a", "b", "c", "d", "file.txt") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSafeLocalFlagPath(t *testing.T) { + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + orig, _ := os.Getwd() + defer os.Chdir(orig) + os.Chdir(dir) + os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0600) + + for _, tt := range []struct { + name string + flag string + value string + want string + wantErr string + }{ + {"empty value passes through", "--image", "", "", ""}, + {"http URL passes through", "--image", "http://example.com/a.jpg", "http://example.com/a.jpg", ""}, + {"https URL passes through", "--image", "https://example.com/a.jpg", "https://example.com/a.jpg", ""}, + {"relative path accepted, returned unchanged", "--file", "photo.jpg", "photo.jpg", ""}, + {"path traversal rejected", "--file", "../escape.txt", "", "--file"}, + {"absolute path rejected", "--image", "/etc/passwd", "", "--image"}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := SafeLocalFlagPath(tt.flag, tt.value) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("SafeLocalFlagPath(%q, %q) error = %v, want contains %q", tt.flag, tt.value, err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("SafeLocalFlagPath(%q, %q) unexpected error: %v", tt.flag, tt.value, err) + } + if got != tt.want { + t.Fatalf("SafeLocalFlagPath(%q, %q) = %q, want %q", tt.flag, tt.value, got, tt.want) + } + }) + } +} + +func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) { + // GIVEN: a real temp file (absolute path under os.TempDir()) + f, err := os.CreateTemp("", "upload-test-*.bin") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + tmpPath := f.Name() + f.Close() + t.Cleanup(func() { os.Remove(tmpPath) }) + + // WHEN: SafeUploadPath validates the absolute temp path + _, err = SafeInputPath(tmpPath) + + // THEN: absolute paths are rejected even in temp dir + if err == nil { + t.Fatal("expected error for absolute temp path, got nil") + } +} + +func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) { + // GIVEN: an absolute path outside the temp directory + // WHEN / THEN: SafeUploadPath rejects it + _, err := SafeInputPath("/etc/passwd") + if err == nil { + t.Error("expected error for absolute non-temp path, got nil") + } +} + +func TestSafeUploadPath_AcceptsRelativePath(t *testing.T) { + // GIVEN: a clean temp CWD with a real file + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + orig, _ := os.Getwd() + defer os.Chdir(orig) + os.Chdir(dir) + + os.WriteFile(filepath.Join(dir, "upload.bin"), []byte("data"), 0600) + + // WHEN: SafeUploadPath validates a relative path to an existing file + got, err := SafeInputPath("upload.bin") + + // THEN: accepted and returned as absolute canonical path + if err != nil { + t.Fatalf("SafeUploadPath(relative) error = %v", err) + } + want := filepath.Join(dir, "upload.bin") + if got != want { + t.Errorf("SafeUploadPath(relative) = %q, want %q", got, want) + } +} + +func TestSafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) { + // GIVEN: an absolute path + + // WHEN: SafeInputPath rejects it + _, err := SafeInputPath("/etc/passwd") + + // THEN: error message mentions --file (not --output) + if err == nil { + t.Fatal("expected error for absolute path") + } + if !strings.Contains(err.Error(), "--file") { + t.Errorf("error should mention --file, got: %s", err.Error()) + } + + // WHEN: SafeOutputPath rejects it + _, err = SafeOutputPath("/etc/passwd") + + // THEN: error message mentions --output (not --file) + if err == nil { + t.Fatal("expected error for absolute path") + } + if !strings.Contains(err.Error(), "--output") { + t.Errorf("error should mention --output, got: %s", err.Error()) + } +} diff --git a/internal/validate/resource.go b/internal/validate/resource.go new file mode 100644 index 00000000..63e13210 --- /dev/null +++ b/internal/validate/resource.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +// unsafeResourceChars matches URL-special characters, control characters, +// and percent signs (to prevent %2e%2e encoding bypass). +var unsafeResourceChars = regexp.MustCompile(`[?#%\x00-\x1f\x7f]`) + +// ResourceName validates an API resource identifier (messageId, fileToken, etc.) +// before it is interpolated into a URL path via fmt.Sprintf. It rejects path +// traversal (..), URL metacharacters (?#%), percent-encoded bypasses (%2e%2e), +// control characters, and dangerous Unicode. +// +// Without this check, an input like "../admin" or "?evil=true" in a message ID +// would alter the API endpoint the request is sent to. Works alongside +// EncodePathSegment for defense-in-depth. +func ResourceName(name, flagName string) error { + if name == "" { + return fmt.Errorf("%s must not be empty", flagName) + } + for _, seg := range strings.Split(name, "/") { + if seg == ".." { + return fmt.Errorf("%s must not contain '..' path traversal", flagName) + } + } + if unsafeResourceChars.MatchString(name) { + return fmt.Errorf("%s contains invalid characters", flagName) + } + for _, r := range name { + if isDangerousUnicode(r) { + return fmt.Errorf("%s contains dangerous Unicode characters", flagName) + } + } + return nil +} + +// EncodePathSegment percent-encodes user input for safe use as a single URL path +// segment (e.g. / → %2F, ? → %3F, # → %23), ensuring the value cannot alter the +// URL routing structure when interpolated into an API path. +// +// This provides defense-in-depth alongside ResourceName: ResourceName rejects known +// dangerous patterns at the input layer, while EncodePathSegment acts as a fallback +// at the concatenation layer — if ResourceName rules are relaxed in the future, or +// if an API path bypasses ResourceName validation (e.g. cmd/service/ generic calls), +// encoding still prevents special characters from being interpreted as path separators +// or query parameters. +// +// Convention: all user-provided variables in fmt.Sprintf API paths within shortcuts/ +// MUST be wrapped with this function. +func EncodePathSegment(s string) string { + return url.PathEscape(s) +} diff --git a/internal/validate/resource_test.go b/internal/validate/resource_test.go new file mode 100644 index 00000000..04fe9220 --- /dev/null +++ b/internal/validate/resource_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "strings" + "testing" +) + +func TestResourceName_RejectsInjectionPatterns(t *testing.T) { + for _, tt := range []struct { + name string + input string + flag string + wantErr bool + }{ + // ── GIVEN: normal API identifiers → THEN: allowed ── + {"normal id", "om_abc123", "--message", false}, + {"file token", "boxcnXYZ789", "--file-token", false}, + {"with slash", "files/abc", "--resource", false}, + {"with underscore", "om_xxx_yyy", "--message", false}, + {"with hyphen", "file-token-123", "--file-token", false}, + {"single char", "a", "--id", false}, + {"slash only", "/", "--id", false}, + + // ── GIVEN: path traversal attempts → THEN: rejected ── + {"dot-dot traversal", "../admin/secret", "--message", true}, + {"mid path traversal", "files/../admin", "--message", true}, + {"bare dot-dot", "..", "--message", true}, + + // ── GIVEN: URL special characters → THEN: rejected ── + {"question mark", "id?admin=true", "--id", true}, + {"hash fragment", "id#section", "--id", true}, + {"percent encoding", "id%2e%2e", "--id", true}, + + // ── GIVEN: control characters → THEN: rejected ── + {"null byte", "id\x00rest", "--id", true}, + {"newline", "id\nrest", "--id", true}, + {"tab", "id\trest", "--id", true}, + {"escape char", "id\x1brest", "--id", true}, + + // ── GIVEN: dangerous Unicode → THEN: rejected ── + {"bidi RLO", "om_\u202Exxx", "--message", true}, + {"zero width space", "om_\u200Bxxx", "--message", true}, + {"BOM", "om_\uFEFFxxx", "--message", true}, + + // ── GIVEN: empty input → THEN: rejected ── + {"empty string", "", "--message", true}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN: ResourceName validates the identifier + err := ResourceName(tt.input, tt.flag) + + // THEN: error matches expectation + if (err != nil) != tt.wantErr { + t.Errorf("ResourceName(%q, %q) error = %v, wantErr %v", + tt.input, tt.flag, err, tt.wantErr) + } + }) + } +} + +func TestResourceName_ErrorMessageContainsFlagName(t *testing.T) { + // GIVEN: an empty resource name with flag "--file-token" + + // WHEN: ResourceName rejects it + err := ResourceName("", "--file-token") + + // THEN: the error message contains the flag name for user-facing diagnostics + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--file-token") { + t.Errorf("error should contain flag name, got: %s", err.Error()) + } +} + +func TestEncodePathSegment_EncodesSpecialCharacters(t *testing.T) { + for _, tt := range []struct { + name string + input string + want string + }{ + // ── GIVEN: safe characters → THEN: unchanged ── + {"normal", "om_abc123", "om_abc123"}, + {"empty", "", ""}, + + // ── GIVEN: URL-special characters → THEN: percent-encoded ── + {"slash", "a/b", "a%2Fb"}, + {"space", "hello world", "hello%20world"}, + {"question mark", "id?foo", "id%3Ffoo"}, + {"hash", "id#bar", "id%23bar"}, + {"dot-dot", "../admin", "..%2Fadmin"}, + {"percent", "50%done", "50%25done"}, + {"unicode", "报告", "%E6%8A%A5%E5%91%8A"}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN: EncodePathSegment encodes the input + got := EncodePathSegment(tt.input) + + // THEN: output matches expected encoding + if got != tt.want { + t.Errorf("EncodePathSegment(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/validate/sanitize.go b/internal/validate/sanitize.go new file mode 100644 index 00000000..1e9bd027 --- /dev/null +++ b/internal/validate/sanitize.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "regexp" + "strings" +) + +// ansiEscape matches ANSI CSI sequences (ESC[ ... letter) and OSC sequences (ESC] ... BEL). +// Private CSI sequences (e.g. ESC[?25l) use the extended parameter byte range [0-9;?>=!]. +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;?>=!]*[a-zA-Z]|\x1b\][^\x07]*\x07`) + +// SanitizeForTerminal strips ANSI escape sequences, C0 control characters +// (except \n and \t), and dangerous Unicode from text, preserving the actual +// readable content. It should be applied to table format output and stderr +// messages, but NOT to json/ndjson output where programmatic consumers need +// the raw data. +// +// API responses may contain injected ANSI sequences that clear the screen, +// fake a colored "OK" status, or change the terminal title. In AI Agent +// scenarios, such injections can also pollute the LLM's context window +// with misleading output. +func SanitizeForTerminal(text string) string { + if strings.ContainsRune(text, '\x1b') { + text = ansiEscape.ReplaceAllString(text, "") + } + var b strings.Builder + b.Grow(len(text)) + for _, r := range text { + switch { + case r == '\n' || r == '\t': + b.WriteRune(r) + case r < 0x20 || r == 0x7f: + continue + case isDangerousUnicode(r): + continue + default: + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/validate/sanitize_test.go b/internal/validate/sanitize_test.go new file mode 100644 index 00000000..be353bdf --- /dev/null +++ b/internal/validate/sanitize_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "testing" +) + +func TestSanitizeForTerminal_StripsEscapesAndDangerousChars(t *testing.T) { + for _, tt := range []struct { + name string + input string + want string + }{ + // ── GIVEN: normal text → THEN: unchanged ── + {"plain text", "hello world", "hello world"}, + {"unicode text", "你好世界", "你好世界"}, + {"empty", "", ""}, + + // ── GIVEN: tab and newline → THEN: preserved ── + {"preserve tab", "col1\tcol2", "col1\tcol2"}, + {"preserve newline", "line1\nline2", "line1\nline2"}, + + // ── GIVEN: ANSI CSI sequences → THEN: stripped, text preserved ── + {"clear screen", "before\x1b[2Jafter", "beforeafter"}, + {"red color", "before\x1b[31mred\x1b[0mafter", "beforeredafter"}, + {"bold", "before\x1b[1mbold\x1b[0mafter", "beforeboldafter"}, + {"cursor move", "before\x1b[10;20Hafter", "beforeafter"}, + {"multiple sequences", "\x1b[31m\x1b[1mhello\x1b[0m", "hello"}, + + // ── GIVEN: ANSI OSC sequences → THEN: stripped ── + {"OSC title change", "before\x1b]0;evil title\x07after", "beforeafter"}, + {"OSC with text", "text\x1b]2;new title\x07more", "textmore"}, + + // ── GIVEN: C0 control characters → THEN: stripped ── + {"null byte", "hello\x00world", "helloworld"}, + {"bell", "hello\x07world", "helloworld"}, + {"backspace", "hello\x08world", "helloworld"}, + {"escape alone", "hello\x1bworld", "helloworld"}, + {"carriage return", "hello\rworld", "helloworld"}, + {"DEL", "hello\x7fworld", "helloworld"}, + + // ── GIVEN: dangerous Unicode → THEN: stripped ── + {"zero width space", "hello\u200Bworld", "helloworld"}, + {"BOM", "hello\uFEFFworld", "helloworld"}, + {"bidi RLO", "hello\u202Eworld", "helloworld"}, + {"bidi LRI", "hello\u2066world", "helloworld"}, + {"line separator", "hello\u2028world", "helloworld"}, + + // ── GIVEN: mixed attack payload → THEN: all dangerous content stripped ── + {"ansi + null + bidi", "\x1b[31m\x00\u202Ehello\x1b[0m", "hello"}, + {"realistic injection", "Status: \x1b[32mOK\x1b[0m (fake)", "Status: OK (fake)"}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN: SanitizeForTerminal processes the input + got := SanitizeForTerminal(tt.input) + + // THEN: output matches expected sanitized result + if got != tt.want { + t.Errorf("SanitizeForTerminal(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestIsDangerousUnicode_IdentifiesAllDangerousRanges(t *testing.T) { + // ── GIVEN: known dangerous Unicode code points → THEN: returns true ── + dangerous := []rune{ + 0x200B, 0x200C, 0x200D, // zero-width + 0xFEFF, // BOM + 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, // bidi + 0x2028, 0x2029, // separators + 0x2066, 0x2067, 0x2068, 0x2069, // isolates + } + for _, r := range dangerous { + if !isDangerousUnicode(r) { + t.Errorf("isDangerousUnicode(%U) = false, want true", r) + } + } + + // ── GIVEN: safe Unicode code points → THEN: returns false ── + safe := []rune{'A', '中', '!', ' ', '\t', '\n', 0x200A, 0x2070} + for _, r := range safe { + if isDangerousUnicode(r) { + t.Errorf("isDangerousUnicode(%U) = true, want false", r) + } + } +} diff --git a/internal/validate/url.go b/internal/validate/url.go new file mode 100644 index 00000000..6d6ab0c4 --- /dev/null +++ b/internal/validate/url.go @@ -0,0 +1,212 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "strings" +) + +const ( + defaultDownloadMaxRedirects = 5 +) + +// DownloadHTTPClientOptions controls redirect and scheme behavior for +// untrusted-source downloads. +type DownloadHTTPClientOptions struct { + // AllowHTTP controls whether plain HTTP URLs are permitted. + // If false, any HTTP URL (initial or redirect target) is rejected. + AllowHTTP bool + // MaxRedirects limits follow-up redirects. Zero or negative uses default. + MaxRedirects int +} + +func isRestrictedDownloadIP(ip net.IP) bool { + if ip == nil { + return true + } + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + if v4 := ip.To4(); v4 != nil { + if v4[0] == 10 || v4[0] == 127 { + return true + } + if v4[0] == 169 && v4[1] == 254 { + return true + } + if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 { + return true + } + if v4[0] == 192 && v4[1] == 168 { + return true + } + if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 { // RFC6598 CGNAT + return true + } + if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) { // RFC2544 benchmarking + return true + } + return false + } + if ip.IsPrivate() { + return true + } + ip16 := ip.To16() + if ip16 == nil { + return true + } + if ip16[0]&0xfe == 0xfc { // fc00::/7 unique local address + return true + } + return false +} + +// ValidateDownloadSourceURL validates a download URL and blocks local/internal targets. +func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil || u == nil { + return fmt.Errorf("invalid URL") + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("only http/https URLs are supported") + } + host := strings.TrimSpace(strings.ToLower(u.Hostname())) + if host == "" { + return fmt.Errorf("URL host is required") + } + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return fmt.Errorf("local/internal host is not allowed") + } + if ip := net.ParseIP(host); ip != nil { + if isRestrictedDownloadIP(ip) { + return fmt.Errorf("local/internal host is not allowed") + } + return nil + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return fmt.Errorf("failed to resolve host") + } + if len(ips) == 0 { + return fmt.Errorf("failed to resolve host") + } + for _, ip := range ips { + if isRestrictedDownloadIP(ip) { + return fmt.Errorf("local/internal host is not allowed") + } + } + return nil +} + +// NewDownloadHTTPClient clones base client and enforces download-safe redirect +// and connection rules for untrusted URLs. +func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *http.Client { + if base == nil { + base = &http.Client{} + } + if opts.MaxRedirects <= 0 { + opts.MaxRedirects = defaultDownloadMaxRedirects + } + + cloned := *base + cloned.Transport = cloneDownloadTransport(base.Transport) + cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= opts.MaxRedirects { + return fmt.Errorf("too many redirects") + } + if len(via) > 0 { + prev := via[len(via)-1] + if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") { + return fmt.Errorf("redirect from https to http is not allowed") + } + } + if !opts.AllowHTTP && !strings.EqualFold(req.URL.Scheme, "https") { + return fmt.Errorf("only https URLs are supported") + } + if err := ValidateDownloadSourceURL(req.Context(), req.URL.String()); err != nil { + return fmt.Errorf("blocked redirect target: %w", err) + } + return nil + } + + return &cloned +} + +func cloneDownloadTransport(base http.RoundTripper) *http.Transport { + var cloned *http.Transport + if src, ok := base.(*http.Transport); ok && src != nil { + cloned = src.Clone() + } else { + if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil { + cloned = def.Clone() + } else { + cloned = &http.Transport{} + } + } + + origDial := cloned.DialContext + cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dialConn(ctx, origDial, network, addr) + if err != nil { + return nil, err + } + if err := validateConnRemoteIP(conn); err != nil { + conn.Close() + return nil, err + } + return conn, nil + } + + if cloned.DialTLSContext != nil { + origDialTLS := cloned.DialTLSContext + cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dialConn(ctx, origDialTLS, network, addr) + if err != nil { + return nil, err + } + if err := validateConnRemoteIP(conn); err != nil { + conn.Close() + return nil, err + } + return conn, nil + } + } + + return cloned +} + +func dialConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) { + if dialFn != nil { + return dialFn(ctx, network, addr) + } + var d net.Dialer + return d.DialContext(ctx, network, addr) +} + +func validateConnRemoteIP(conn net.Conn) error { + if conn == nil { + return fmt.Errorf("nil connection") + } + raddr := conn.RemoteAddr() + if raddr == nil { + return fmt.Errorf("missing remote address") + } + host, _, err := net.SplitHostPort(raddr.String()) + if err != nil { + host = raddr.String() + } + ip := net.ParseIP(strings.Trim(host, "[]")) + if ip == nil { + return fmt.Errorf("invalid remote IP") + } + if isRestrictedDownloadIP(ip) { + return fmt.Errorf("local/internal host is not allowed") + } + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 00000000..568ddfd9 --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT +// +// lark-cli — Feishu/Lark CLI tool (Go implementation). +package main + +import ( + "os" + + "github.com/larksuite/cli/cmd" +) + +func main() { + os.Exit(cmd.Execute()) +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..13e68149 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "@larksuite/cli", + "version": "1.0.0", + "description": "The official CLI for Lark/Feishu open platform", + "bin": { + "lark-cli": "scripts/run.js" + }, + "scripts": { + "postinstall": "node scripts/install.js" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ], + "engines": { + "node": ">=16" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/larksuite/cli.git" + }, + "license": "MIT", + "files": [ + "scripts/install.js", + "scripts/run.js", + "CHANGELOG.md" + ] +} diff --git a/scripts/fetch_meta.py b/scripts/fetch_meta.py new file mode 100644 index 00000000..4c0145b4 --- /dev/null +++ b/scripts/fetch_meta.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Fetch meta_data.json from remote API for build-time embedding. + +Usage: + python3 scripts/fetch_meta.py # fetch from feishu (default) + python3 scripts/fetch_meta.py --brand lark # fetch from larksuite +""" + +import argparse +import json +import os +import subprocess +import sys +import urllib.request +import urllib.error + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.join(SCRIPT_DIR, "..") +OUT_PATH = os.path.join(ROOT, "internal", "registry", "meta_data.json") + +API_HOSTS = { + "feishu": "https://open.feishu.cn/api/tools/open/api_definition", + "lark": "https://open.larksuite.com/api/tools/open/api_definition", +} + +TIMEOUT = 10 # seconds + + +def get_version(): + """Get version from git tags, matching Makefile logic.""" + try: + return subprocess.check_output( + ["git", "describe", "--tags", "--always", "--dirty"], + stderr=subprocess.DEVNULL, + text=True, + cwd=ROOT, + ).strip() + except Exception: + return "dev" + + +def fetch_remote(brand): + """Fetch MergedRegistry from remote API.""" + base = API_HOSTS.get(brand, API_HOSTS["feishu"]) + version = get_version() + url = f"{base}?protocol=meta&client_version={urllib.request.quote(version)}" + + print(f"fetch-meta: GET {url}", file=sys.stderr) + req = urllib.request.Request(url) + resp = urllib.request.urlopen(req, timeout=TIMEOUT) + body = resp.read() + + envelope = json.loads(body) + if envelope.get("msg") != "succeeded": + raise RuntimeError(f"unexpected response msg: {envelope.get('msg')!r}") + + data = envelope.get("data", {}) + if not data.get("services"): + raise RuntimeError("remote returned empty services") + + return data + + +def main(): + parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding") + parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"], + help="API brand (default: feishu)") + args = parser.parse_args() + + data = fetch_remote(args.brand) + count = len(data.get("services", [])) + print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr) + + with open(OUT_PATH, "w") as fp: + json.dump(data, fp, ensure_ascii=False, indent=2) + fp.write("\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/install.js b/scripts/install.js new file mode 100644 index 00000000..db0eb4ff --- /dev/null +++ b/scripts/install.js @@ -0,0 +1,100 @@ +const fs = require("fs"); +const path = require("path"); +const https = require("https"); +const { execSync } = require("child_process"); +const os = require("os"); + +const VERSION = require("../package.json").version; +const REPO = "larksuite/cli"; +const NAME = "lark-cli"; + +const PLATFORM_MAP = { + darwin: "darwin", + linux: "linux", + win32: "windows", +}; + +const ARCH_MAP = { + x64: "amd64", + arm64: "arm64", +}; + +const platform = PLATFORM_MAP[process.platform]; +const arch = ARCH_MAP[process.arch]; + +if (!platform || !arch) { + console.error( + `Unsupported platform: ${process.platform}-${process.arch}` + ); + process.exit(1); +} + +const isWindows = process.platform === "win32"; +const ext = isWindows ? ".zip" : ".tar.gz"; +const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`; +const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`; +const binDir = path.join(__dirname, "..", "bin"); +const dest = path.join(binDir, NAME + (isWindows ? ".exe" : "")); + +fs.mkdirSync(binDir, { recursive: true }); + +function download(url, destPath) { + return new Promise((resolve, reject) => { + const client = url.startsWith("https") ? https : require("http"); + client + .get(url, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + return download(res.headers.location, destPath).then( + resolve, + reject + ); + } + if (res.statusCode !== 200) { + return reject( + new Error(`Download failed with status ${res.statusCode}: ${url}`) + ); + } + const file = fs.createWriteStream(destPath); + res.pipe(file); + file.on("finish", () => { + file.close(); + resolve(); + }); + }) + .on("error", reject); + }); +} + +async function install() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-")); + const archivePath = path.join(tmpDir, archiveName); + + try { + await download(url, archivePath); + + if (isWindows) { + execSync( + `powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`, + { stdio: "ignore" } + ); + } else { + execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { + stdio: "ignore", + }); + } + + const binaryName = NAME + (isWindows ? ".exe" : ""); + const extractedBinary = path.join(tmpDir, binaryName); + + fs.copyFileSync(extractedBinary, dest); + fs.chmodSync(dest, 0o755); + console.log(`${NAME} v${VERSION} installed successfully`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +install().catch((err) => { + console.error(`Failed to install ${NAME}:`, err.message); + process.exit(1); +}); diff --git a/scripts/run.js b/scripts/run.js new file mode 100644 index 00000000..1b2477e8 --- /dev/null +++ b/scripts/run.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +const { execFileSync } = require("child_process"); +const path = require("path"); + +const ext = process.platform === "win32" ? ".exe" : ""; +const bin = path.join(__dirname, "..", "bin", "lark-cli" + ext); + +try { + execFileSync(bin, process.argv.slice(2), { stdio: "inherit" }); +} catch (e) { + process.exit(e.status || 1); +} diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh new file mode 100755 index 00000000..c3b486f9 --- /dev/null +++ b/scripts/tag-release.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Read version from package.json +VERSION=$(node -p "require('${REPO_ROOT}/package.json').version") + +if [ -z "$VERSION" ]; then + echo "Error: could not read version from package.json" >&2 + exit 1 +fi + +TAG="v${VERSION}" + +echo "Version: ${VERSION}" +echo "Tag: ${TAG}" + +# Check if tag already exists locally +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists locally, skipping." + exit 0 +fi + +# Check if tag already exists on remote +if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then + echo "Tag ${TAG} already exists on remote, skipping." + exit 0 +fi + +# Ensure package.json changes are committed before tagging +if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then + echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2 + exit 1 +fi + +# Ensure current branch is pushed to remote before tagging +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +LOCAL_SHA=$(git rev-parse HEAD) +REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "") +if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then + echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2 + exit 1 +fi + +# Create and push tag +git tag "$TAG" +git push origin "$TAG" + +echo "Successfully created and pushed tag ${TAG}" diff --git a/shortcuts/base/base_advperm_disable.go b/shortcuts/base/base_advperm_disable.go new file mode 100644 index 00000000..3266d9bc --- /dev/null +++ b/shortcuts/base/base_advperm_disable.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseAdvpermDisable = common.Shortcut{ + Service: "base", + Command: "+advperm-disable", + Description: "Disable advanced permissions for a Base", + Risk: "high-risk-write", + Scopes: []string{"base:app:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PUT("/open-apis/base/v3/bases/:base_token/advperm/enable?enable=false"). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + + queryParams := make(larkcore.QueryParams) + queryParams.Set("enable", "false") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPut, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/advperm/enable", validate.EncodePathSegment(baseToken)), + QueryParams: queryParams, + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "disable advanced permissions failed") + }, +} diff --git a/shortcuts/base/base_advperm_enable.go b/shortcuts/base/base_advperm_enable.go new file mode 100644 index 00000000..2f7437ca --- /dev/null +++ b/shortcuts/base/base_advperm_enable.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseAdvpermEnable = common.Shortcut{ + Service: "base", + Command: "+advperm-enable", + Description: "Enable advanced permissions for a Base", + Risk: "write", + Scopes: []string{"base:app:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PUT("/open-apis/base/v3/bases/:base_token/advperm/enable?enable=true"). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + + queryParams := make(larkcore.QueryParams) + queryParams.Set("enable", "true") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPut, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/advperm/enable", validate.EncodePathSegment(baseToken)), + QueryParams: queryParams, + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed") + }, +} diff --git a/shortcuts/base/base_advperm_test.go b/shortcuts/base/base_advperm_test.go new file mode 100644 index 00000000..db0be579 --- /dev/null +++ b/shortcuts/base/base_advperm_test.go @@ -0,0 +1,238 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// --------------------------------------------------------------------------- +// Validate tests +// --------------------------------------------------------------------------- + +func TestBaseAdvpermEnableValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": ""}, nil, nil) + if err := BaseAdvpermEnable.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("whitespace base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": " "}, nil, nil) + if err := BaseAdvpermEnable.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + if err := BaseAdvpermEnable.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseAdvpermDisableValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": ""}, nil, nil) + if err := BaseAdvpermDisable.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("whitespace base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": " "}, nil, nil) + if err := BaseAdvpermDisable.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + if err := BaseAdvpermDisable.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +// --------------------------------------------------------------------------- +// DryRun tests +// --------------------------------------------------------------------------- + +func TestBaseAdvpermEnableDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + dr := BaseAdvpermEnable.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestBaseAdvpermDisableDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + dr := BaseAdvpermDisable.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +// --------------------------------------------------------------------------- +// Metadata tests +// --------------------------------------------------------------------------- + +func TestBaseAdvpermMetadata(t *testing.T) { + t.Run("enable", func(t *testing.T) { + s := BaseAdvpermEnable + if s.Command != "+advperm-enable" { + t.Fatalf("command=%q", s.Command) + } + if s.Risk != "write" { + t.Fatalf("risk=%q", s.Risk) + } + if s.Service != "base" { + t.Fatalf("service=%q", s.Service) + } + if len(s.Scopes) != 1 || s.Scopes[0] != "base:app:update" { + t.Fatalf("scopes=%v", s.Scopes) + } + }) + + t.Run("disable", func(t *testing.T) { + s := BaseAdvpermDisable + if s.Command != "+advperm-disable" { + t.Fatalf("command=%q", s.Command) + } + if s.Risk != "high-risk-write" { + t.Fatalf("risk=%q", s.Risk) + } + if s.Service != "base" { + t.Fatalf("service=%q", s.Service) + } + if len(s.Scopes) != 1 || s.Scopes[0] != "base:app:update" { + t.Fatalf("scopes=%v", s.Scopes) + } + }) +} + +// --------------------------------------------------------------------------- +// Execute tests (happy path) +// --------------------------------------------------------------------------- + +func TestBaseAdvpermEnableExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": nil, + }, + }) + args := []string{"+advperm-enable", "--base-token", "app_x"} + if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "success") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseAdvpermDisableExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": nil, + }, + }) + args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"} + if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "success") { + t.Fatalf("stdout=%s", got) + } +} + +// --------------------------------------------------------------------------- +// Execute error paths +// --------------------------------------------------------------------------- + +func TestBaseAdvpermEnableExecuteTransportError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Status: 500, + Body: "internal server error", + }) + args := []string{"+advperm-enable", "--base-token", "app_x"} + if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil { + t.Fatal("expected error") + } +} + +func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Body: map[string]interface{}{ + "code": 190001, + "msg": "bad request", + }, + }) + args := []string{"+advperm-enable", "--base-token", "app_x"} + if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Status: 500, + Body: "internal server error", + }) + args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"} + if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil { + t.Fatal("expected error") + } +} + +func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Body: map[string]interface{}{ + "code": 190002, + "msg": "permission denied", + }, + }) + args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"} + if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") { + t.Fatalf("err=%v", err) + } +} diff --git a/shortcuts/base/base_command_common.go b/shortcuts/base/base_command_common.go new file mode 100644 index 00000000..58c3be8e --- /dev/null +++ b/shortcuts/base/base_command_common.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import "github.com/larksuite/cli/shortcuts/common" + +func authTypes() []string { + return []string{"user", "bot"} +} + +func baseTokenFlag(required bool) common.Flag { + return common.Flag{Name: "base-token", Desc: "base token", Required: required} +} + +func tableRefFlag(required bool) common.Flag { + return common.Flag{Name: "table-id", Desc: "table ID or name", Required: required} +} + +func fieldRefFlag(required bool) common.Flag { + return common.Flag{Name: "field-id", Desc: "field ID or name", Required: required} +} + +func viewRefFlag(required bool) common.Flag { + return common.Flag{Name: "view-id", Desc: "view ID or name", Required: required} +} + +func recordRefFlag(required bool) common.Flag { + return common.Flag{Name: "record-id", Desc: "record ID", Required: required} +} diff --git a/shortcuts/base/base_copy.go b/shortcuts/base/base_copy.go new file mode 100644 index 00000000..ff33f0a1 --- /dev/null +++ b/shortcuts/base/base_copy.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseCopy = common.Shortcut{ + Service: "base", + Command: "+base-copy", + Description: "Copy a base resource", + Risk: "write", + Scopes: []string{"base:app:copy"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "name", Desc: "new base name"}, + {Name: "folder-token", Desc: "folder token for destination"}, + {Name: "without-content", Type: "bool", Desc: "copy structure only"}, + {Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"}, + }, + DryRun: dryRunBaseCopy, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseCopy(runtime) + }, +} diff --git a/shortcuts/base/base_create.go b/shortcuts/base/base_create.go new file mode 100644 index 00000000..b5f69a1e --- /dev/null +++ b/shortcuts/base/base_create.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseCreate = common.Shortcut{ + Service: "base", + Command: "+base-create", + Description: "Create a new base resource", + Risk: "write", + Scopes: []string{"base:app:create"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + {Name: "name", Desc: "base name", Required: true}, + {Name: "folder-token", Desc: "folder token for destination"}, + {Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"}, + }, + DryRun: dryRunBaseCreate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseCreate(runtime) + }, +} diff --git a/shortcuts/base/base_dashboard_execute_test.go b/shortcuts/base/base_dashboard_execute_test.go new file mode 100644 index 00000000..5fbf43af --- /dev/null +++ b/shortcuts/base/base_dashboard_execute_test.go @@ -0,0 +1,625 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// ── Dashboard CRUD ────────────────────────────────────────────────── + +func TestBaseDashboardExecuteList(t *testing.T) { + t.Run("single page", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "total": 2, + "items": []interface{}{ + map[string]interface{}{"dashboard_id": "dsh_001", "name": "销售报表"}, + map[string]interface{}{"dashboard_id": "dsh_002", "name": "运营看板"}, + }, + }, + }, + }) + if err := runShortcut(t, BaseDashboardList, []string{"+dashboard-list", "--base-token", "app_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dsh_001"`) || !strings.Contains(got, `"dsh_002"`) { + t.Fatalf("stdout=%s", got) + } + }) + +} + +func TestBaseDashboardExecuteGet(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dashboard_id": "dsh_001", + "name": "销售报表", + "theme": map[string]interface{}{"theme_style": "default"}, + "blocks": []interface{}{ + map[string]interface{}{"block_id": "blk_a", "block_name": "柱状图", "block_type": "column"}, + }, + }, + }, + }) + if err := runShortcut(t, BaseDashboardGet, []string{"+dashboard-get", "--base-token", "app_x", "--dashboard-id", "dsh_001"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dsh_001"`) || !strings.Contains(got, `"销售报表"`) || !strings.Contains(got, `"dashboard"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardExecuteCreate(t *testing.T) { + t.Run("name only", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dashboard_id": "dsh_new", + "name": "新报表", + }, + }, + }) + if err := runShortcut(t, BaseDashboardCreate, []string{"+dashboard-create", "--base-token", "app_x", "--name", "新报表"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dsh_new"`) || !strings.Contains(got, `"created": true`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("with theme", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dashboard_id": "dsh_themed", + "name": "主题报表", + "theme": map[string]interface{}{"theme_style": "SimpleBlue"}, + }, + }, + }) + if err := runShortcut(t, BaseDashboardCreate, []string{"+dashboard-create", "--base-token", "app_x", "--name", "主题报表", "--theme-style", "SimpleBlue"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dsh_themed"`) || !strings.Contains(got, `"SimpleBlue"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseDashboardExecuteUpdate(t *testing.T) { + t.Run("update name", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dashboard_id": "dsh_001", + "name": "更新后的名称", + }, + }, + }) + if err := runShortcut(t, BaseDashboardUpdate, []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--name", "更新后的名称"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"更新后的名称"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("update theme", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dashboard_id": "dsh_001", + "name": "报表", + "theme": map[string]interface{}{"theme_style": "deepDark"}, + }, + }, + }) + if err := runShortcut(t, BaseDashboardUpdate, []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--theme-style", "deepDark"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"deepDark"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseDashboardExecuteDelete(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseDashboardDelete, []string{"+dashboard-delete", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"dashboard_id": "dsh_001"`) { + t.Fatalf("stdout=%s", got) + } +} + +// ── Dashboard Block CRUD ──────────────────────────────────────────── + +func TestBaseDashboardBlockExecuteList(t *testing.T) { + t.Run("single page", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "total": 2, + "items": []interface{}{ + map[string]interface{}{"block_id": "blk_a", "name": "柱状图", "type": "column"}, + map[string]interface{}{"block_id": "blk_b", "name": "指标卡", "type": "statistics"}, + }, + }, + }, + }) + if err := runShortcut(t, BaseDashboardBlockList, []string{"+dashboard-block-list", "--base-token", "app_x", "--dashboard-id", "dsh_001"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_a"`) || !strings.Contains(got, `"blk_b"`) { + t.Fatalf("stdout=%s", got) + } + }) + +} + +func TestBaseDashboardBlockExecuteGet(t *testing.T) { + t.Run("basic", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_a", + "name": "订单趋势", + "type": "column", + "layout": map[string]interface{}{"x": 0, "y": 0, "w": 12, "h": 6}, + "data_config": map[string]interface{}{ + "table_name": "订单表", + "count_all": true, + }, + }, + }, + }) + if err := runShortcut(t, BaseDashboardBlockGet, []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_a"`) || !strings.Contains(got, `"block"`) || !strings.Contains(got, `"订单趋势"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("with user-id-type", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "user_id_type=union_id", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_a", + "name": "人员图表", + "type": "pie", + }, + }, + }) + if err := runShortcut(t, BaseDashboardBlockGet, []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", "--user-id-type", "union_id"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_a"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseDashboardBlockExecuteCreate(t *testing.T) { + t.Run("with data-config", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_new", + "name": "订单趋势", + "type": "column", + "layout": map[string]interface{}{"x": 0, "y": 0, "w": 12, "h": 6}, + "data_config": map[string]interface{}{ + "table_name": "订单表", + "count_all": true, + }, + }, + }, + }) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--name", "订单趋势", "--type", "column", + "--data-config", `{"table_name":"订单表","count_all":true}`} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_new"`) || !strings.Contains(got, `"created": true`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("statistics with series", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_stat", + "name": "销售总额", + "type": "statistics", + }, + }, + }) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--name", "销售总额", "--type", "statistics", + "--data-config", `{"table_name":"数据表","series":[{"field_name":"数字","rollup":"SUM"}]}`} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_stat"`) || !strings.Contains(got, `"created": true`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("without data-config", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_empty", + "name": "空图表", + "type": "line", + }, + }, + }) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--name", "空图表", "--type", "line"} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_empty"`) || !strings.Contains(got, `"created": true`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("invalid data-config json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--name", "Test", "--type", "column", "--data-config", "not-json"} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err == nil { + t.Fatalf("expected error for invalid data-config JSON") + } + }) +} + +func TestBaseDashboardBlockExecuteUpdate(t *testing.T) { + t.Run("update name and data-config", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_a", + "name": "订单趋势v2", + "type": "column", + "data_config": map[string]interface{}{ + "table_name": "订单表2", + "count_all": true, + }, + }, + }, + }) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", + "--name", "订单趋势v2", + "--data-config", `{"table_name":"订单表2","count_all":true}`} + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"订单趋势v2"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("update name only", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_a", + "name": "仅改名", + "type": "column", + }, + }, + }) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", + "--name", "仅改名"} + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"仅改名"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("invalid data-config json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", + "--data-config", "bad-json"} + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err == nil { + t.Fatalf("expected error for invalid data-config JSON") + } + }) +} + +func TestBaseDashboardBlockExecuteDelete(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseDashboardBlockDelete, []string{"+dashboard-block-delete", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"block_id": "blk_a"`) { + t.Fatalf("stdout=%s", got) + } +} + +// ── Dry Run: Dashboard & Blocks ────────────────────────────────────── + +func TestBaseDashboardDryRun_List(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + if err := runShortcut(t, BaseDashboardList, []string{"+dashboard-list", "--base-token", "app_x", "--page-size", "50", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards") || !strings.Contains(got, "page_size=50") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardDryRun_Get(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + if err := runShortcut(t, BaseDashboardGet, []string{"+dashboard-get", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/dsh_1") || !strings.Contains(got, "dsh_1") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardDryRun_Create(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-create", "--base-token", "app_x", "--name", "新报表", "--theme-style", "default", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "POST /open-apis/base/v3/bases/app_x/dashboards") || !strings.Contains(got, "\"name\":\"新报表\"") || !strings.Contains(got, "theme_style") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardDryRun_Update(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "更新名", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "PATCH /open-apis/base/v3/bases/app_x/dashboards/dsh_1") || !strings.Contains(got, "\"name\":\"更新名\"") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardDryRun_Delete(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-delete", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardDelete, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "DELETE /open-apis/base/v3/bases/app_x/dashboards/dsh_1") || !strings.Contains(got, "dsh_1") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockDryRun_List(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-list", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--page-size", "10", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockList, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks") || !strings.Contains(got, "page_size=10") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockDryRun_Get(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--user-id-type", "union_id", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockGet, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a") || !strings.Contains(got, "union_id") || !strings.Contains(got, "blk_a") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockDryRun_Create(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "订单趋势", "--type", "column", "--data-config", `{"table_name":"订单表","count_all":true}`, "--user-id-type", "open_id", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "POST /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks") || !strings.Contains(got, "\"name\":\"订单趋势\"") || !strings.Contains(got, "table_name") || !strings.Contains(got, "open_id") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockDryRun_Update(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--name", "订单趋势v2", "--data-config", `{"table_name":"订单表2","count_all":true}`, "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "PATCH /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a") || !strings.Contains(got, "订单趋势v2") || !strings.Contains(got, "订单表2") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockDryRun_Delete(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-delete", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockDelete, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "DELETE /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a") || !strings.Contains(got, "blk_a") { + t.Fatalf("stdout=%s", got) + } +} + +// ── Validator: data_config ─────────────────────────────────────────── + +func TestBaseDashboardBlockCreate_ValidateFails(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + // 缺 table_name 且 series 与 count_all 同时存在 + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "Bad", "--type", "column", + "--data-config", `{"series":[{"field_name":"金额","rollup":"sum"}],"count_all":true}`, + } + err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout) + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "data_config 校验失败") || !strings.Contains(err.Error(), "table_name") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBaseDashboardBlockCreate_NoValidateFlagAllocs(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block_id": "blk_ok", "name": "OK", "type": "column"}}, + }) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "OK", "--type", "column", "--no-validate", + "--data-config", `{"series":[{"field_name":"金额","rollup":"sum"}],"count_all":true}`, + } + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "\"blk_ok\"") || !strings.Contains(got, "\"created\": true") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + // 合法 JSON,但 rollup=COUNTA(不支持) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "Bad", "--type", "column", + "--data-config", `{"table_name":"T","series":[{"field_name":"金额","rollup":"COUNTA"}]}`, + } + err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout) + if err == nil { + t.Fatalf("expected validation error for invalid rollup") + } + if got := err.Error(); !strings.Contains(got, "rollup") || !strings.Contains(got, "data_config 校验失败") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/base/base_data_query.go b/shortcuts/base/base_data_query.go new file mode 100644 index 00000000..d316e4f2 --- /dev/null +++ b/shortcuts/base/base_data_query.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDataQuery = common.Shortcut{ + Service: "base", + Command: "+data-query", + Description: "Query and analyze Bitable data with JSON DSL (aggregation, filter, sort)", + Risk: "read", + Scopes: []string{"base:table:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "dsl", Desc: "query JSON DSL (LiteQuery Protocol)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + var dsl map[string]interface{} + dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl")))) + dec.UseNumber() + if err := dec.Decode(&dsl); err != nil { + return common.FlagErrorf("--dsl invalid JSON: %v", err) + } + _, hasDim := dsl["dimensions"] + _, hasMeas := dsl["measures"] + if !hasDim && !hasMeas { + return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + var dsl map[string]interface{} + dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl")))) + dec.UseNumber() + dec.Decode(&dsl) + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/data/query"). + Body(dsl). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + + var dsl map[string]interface{} + dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl")))) + dec.UseNumber() + dec.Decode(&dsl) + + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "data/query"), nil, dsl) + if err != nil { + return err + } + + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go new file mode 100644 index 00000000..3898826b --- /dev/null +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -0,0 +1,220 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "testing" +) + +func assertDryRunContains(t *testing.T, dr interface{ Format() string }, wants ...string) { + t.Helper() + out := dr.Format() + for _, want := range wants { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q\noutput:\n%s", want, out) + } + } +} + +func TestDryRunTableOps(t *testing.T) { + ctx := context.Background() + + listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, map[string]int{"offset": -1, "limit": 999}) + assertDryRunContains(t, dryRunTableList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables", "offset=0", "limit=100") + + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_1", "name": "Orders"}, nil, nil) + assertDryRunContains(t, dryRunTableGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1") + assertDryRunContains(t, dryRunTableCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables") + assertDryRunContains(t, dryRunTableUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1") + assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1") +} + +func TestDryRunFieldOps(t *testing.T) { + ctx := context.Background() + + listRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, + nil, + map[string]int{"offset": -2, "limit": 999}, + ) + assertDryRunContains(t, dryRunFieldList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "offset=0", "limit=200") + + rt := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "field-id": "fld_1", + "json": `{"name":"Amount","type":"number"}`, + "keyword": " open ", + }, + nil, + map[string]int{"offset": 3, "limit": 0}, + ) + assertDryRunContains(t, dryRunFieldGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") + assertDryRunContains(t, dryRunFieldCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/fields") + assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") + assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") + assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open") +} + +func TestDryRunRecordOps(t *testing.T) { + ctx := context.Background() + + listRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1"}, + nil, + map[string]int{"offset": -3, "limit": 500}, + ) + assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1") + + upsertCreateRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`}, + nil, nil, + ) + assertDryRunContains(t, dryRunRecordUpsert(ctx, upsertCreateRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records") + + rt := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "record-id": "rec_1", "json": `{"Name":"B"}`}, + nil, + map[string]int{"max-version": 11, "page-size": 30}, + ) + assertDryRunContains(t, dryRunRecordGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1") + assertDryRunContains(t, dryRunRecordUpsert(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1") + assertDryRunContains(t, dryRunRecordDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1") + assertDryRunContains(t, dryRunRecordHistoryList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/record_history", "max_version=11", "page_size=30", "record_id=rec_1", "table_id=tbl_1") + + uploadAttachmentRT := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "record-id": "rec_1", + "field-id": "fld_att", + "file": "/tmp/report.pdf", + "name": "report-final.pdf", + }, + nil, + nil, + ) + assertDryRunContains(t, + BaseRecordUploadAttachment.DryRun(ctx, uploadAttachmentRT), + "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_att", + "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1", + "POST /open-apis/drive/v1/medias/upload_all", + "bitable_file", + "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1", + "report-final.pdf", + "deprecated_set_attachment", + ) +} + +func TestDryRunBaseOps(t *testing.T) { + ctx := context.Background() + + getRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + assertDryRunContains(t, dryRunBaseGet(ctx, getRT), "GET /open-apis/base/v3/bases/app_x") + + copyRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "name": "Copied", "folder-token": "fld_x", "time-zone": "Asia/Shanghai"}, + map[string]bool{"without-content": true}, + nil, + ) + assertDryRunContains(t, dryRunBaseCopy(ctx, copyRT), "POST /open-apis/base/v3/bases/app_x/copy") + + createRT := newBaseTestRuntime( + map[string]string{"name": "New Base", "folder-token": "fld_y", "time-zone": "Asia/Shanghai"}, + nil, + nil, + ) + assertDryRunContains(t, dryRunBaseCreate(ctx, createRT), "POST /open-apis/base/v3/bases") +} + +func TestDryRunDashboardOps(t *testing.T) { + ctx := context.Background() + + rt := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "dashboard-id": "dash_1", + "block-id": "blk_1", + "name": "Main", + "theme-style": "light", + "type": "bar", + "data-config": `{"table_name":"orders"}`, + "user-id-type": "open_id", + "page-size": "50", + "page-token": "pt_1", + }, + nil, + nil, + ) + + assertDryRunContains(t, dryRunDashboardList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards", "page_size=50", "page_token=pt_1") + assertDryRunContains(t, dryRunDashboardGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/dash_1") + assertDryRunContains(t, dryRunDashboardCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/dashboards") + assertDryRunContains(t, dryRunDashboardUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/dashboards/dash_1") + assertDryRunContains(t, dryRunDashboardDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/dashboards/dash_1") + + assertDryRunContains(t, dryRunDashboardBlockList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks", "page_size=50", "page_token=pt_1") + assertDryRunContains(t, dryRunDashboardBlockGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1", "user_id_type=open_id") + assertDryRunContains(t, dryRunDashboardBlockCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks", "user_id_type=open_id") + assertDryRunContains(t, dryRunDashboardBlockUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1", "user_id_type=open_id") + assertDryRunContains(t, dryRunDashboardBlockDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1") +} + +func TestDryRunViewOps(t *testing.T) { + ctx := context.Background() + + listRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1"}, + nil, + map[string]int{"offset": -1, "limit": 500}, + ) + assertDryRunContains(t, dryRunViewList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views", "offset=0", "limit=200") + assertDryRunContains(t, dryRunViewGet(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1") + assertDryRunContains(t, dryRunViewDelete(ctx, listRT), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1") + + createValidRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `[{"name":"Main"}]`}, + nil, nil, + ) + assertDryRunContains(t, dryRunViewCreate(ctx, createValidRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/views") + + createInvalidRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{`}, + nil, nil, + ) + assertDryRunContains(t, dryRunViewCreate(ctx, createInvalidRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/views") + + setJSONObjectRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1", "json": `{"enabled":true}`, "name": "New View"}, + nil, nil, + ) + assertDryRunContains(t, dryRunViewSetFilter(ctx, setJSONObjectRT), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/filter") + assertDryRunContains(t, dryRunViewSetTimebar(ctx, setJSONObjectRT), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/timebar") + assertDryRunContains(t, dryRunViewSetCard(ctx, setJSONObjectRT), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/card") + assertDryRunContains(t, dryRunViewRename(ctx, setJSONObjectRT), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1") + + setWrappedRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1", "json": `[{"field":"fld_status"}]`}, + nil, nil, + ) + assertDryRunContains(t, dryRunViewSetGroup(ctx, setWrappedRT), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/group") + assertDryRunContains(t, dryRunViewSetSort(ctx, setWrappedRT), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/sort") + + setWrappedInvalidRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1", "json": `{`}, + nil, nil, + ) + assertDryRunContains(t, dryRunViewSetWrapped(setWrappedInvalidRT, "group", "group_config"), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/group") + + assertDryRunContains(t, dryRunViewGetFilter(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/filter") + assertDryRunContains(t, dryRunViewGetGroup(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/group") + assertDryRunContains(t, dryRunViewGetSort(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/sort") + assertDryRunContains(t, dryRunViewGetTimebar(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/timebar") + assertDryRunContains(t, dryRunViewGetCard(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/card") + + assertDryRunContains(t, dryRunViewGetProperty(listRT, "a/b"), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/a%2Fb") +} diff --git a/shortcuts/base/base_errors.go b/shortcuts/base/base_errors.go new file mode 100644 index 00000000..718c15d3 --- /dev/null +++ b/shortcuts/base/base_errors.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" +) + +func handleBaseAPIResult(result interface{}, err error, action string) (map[string]interface{}, error) { + data, err := handleBaseAPIResultAny(result, err, action) + if err != nil { + return nil, err + } + dataMap, _ := data.(map[string]interface{}) + return dataMap, nil +} + +func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) { + if err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err) + } + + resultMap, _ := result.(map[string]interface{}) + code, _ := util.ToFloat64(resultMap["code"]) + if code == 0 { + return resultMap["data"], nil + } + + larkCode := int(code) + msg := extractDataErrorMessage(resultMap) + if strings.TrimSpace(msg) == "" { + msg, _ = resultMap["msg"].(string) + } + + fullMsg := fmt.Sprintf("%s: [%d] %s", action, larkCode, msg) + detail := extractErrorDetail(resultMap) + apiErr := output.ErrAPI(larkCode, fullMsg, detail) + if apiErr.Detail != nil && apiErr.Detail.Hint == "" { + if hint := extractErrorHint(resultMap); hint != "" { + apiErr.Detail.Hint = hint + } + } + return nil, apiErr +} + +func extractErrorDetail(resultMap map[string]interface{}) interface{} { + if detail, ok := nonNilMapValue(resultMap, "error"); ok { + return detail + } + data, _ := resultMap["data"].(map[string]interface{}) + if detail, ok := nonNilMapValue(data, "error"); ok { + return detail + } + return nil +} + +func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) { + if src == nil { + return nil, false + } + value, ok := src[key] + if !ok { + return nil, false + } + switch value.(type) { + case nil: + return nil, false + default: + return value, true + } +} + +func extractErrorHint(resultMap map[string]interface{}) string { + if detail, ok := resultMap["error"].(map[string]interface{}); ok { + if hint, _ := detail["hint"].(string); strings.TrimSpace(hint) != "" { + return hint + } + } + data, _ := resultMap["data"].(map[string]interface{}) + if detail, ok := data["error"].(map[string]interface{}); ok { + if hint, _ := detail["hint"].(string); strings.TrimSpace(hint) != "" { + return hint + } + } + return "" +} + +func extractDataErrorMessage(resultMap map[string]interface{}) string { + data, _ := resultMap["data"].(map[string]interface{}) + if detail, ok := data["error"].(map[string]interface{}); ok { + if message, _ := detail["message"].(string); strings.TrimSpace(message) != "" { + return message + } + } + return "" +} diff --git a/shortcuts/base/base_errors_test.go b/shortcuts/base/base_errors_test.go new file mode 100644 index 00000000..5b86c8ae --- /dev/null +++ b/shortcuts/base/base_errors_test.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" +) + +func TestErrorDetailHelpers(t *testing.T) { + if value, ok := nonNilMapValue(nil, "error"); ok || value != nil { + t.Fatalf("nil map should not return value") + } + if value, ok := nonNilMapValue(map[string]interface{}{"error": nil}, "error"); ok || value != nil { + t.Fatalf("nil entry should not return value") + } + detail := map[string]interface{}{"message": "boom", "hint": "retry later"} + if value, ok := nonNilMapValue(map[string]interface{}{"error": detail}, "error"); !ok || value == nil { + t.Fatalf("expected non-nil detail") + } + if got := extractErrorDetail(map[string]interface{}{"error": detail}); got == nil { + t.Fatalf("expected root detail") + } + if got := extractErrorDetail(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got == nil { + t.Fatalf("expected nested detail") + } + if got := extractErrorHint(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "retry later" { + t.Fatalf("hint=%q", got) + } + if got := extractDataErrorMessage(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "boom" { + t.Fatalf("message=%q", got) + } + if got := extractDataErrorMessage(map[string]interface{}{"data": map[string]interface{}{}}); got != "" { + t.Fatalf("message=%q", got) + } +} + +func TestHandleBaseAPIResultErrorPaths(t *testing.T) { + if _, err := handleBaseAPIResultAny(nil, assertErr{}, "list fields"); err == nil || !strings.Contains(err.Error(), "list fields") { + t.Fatalf("err=%v", err) + } + result := map[string]interface{}{ + "code": 190001, + "msg": "bad request", + "data": map[string]interface{}{ + "error": map[string]interface{}{"message": "invalid filter", "hint": "check field name"}, + }, + } + if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") || !strings.Contains(err.Error(), "190001") { + t.Fatalf("err=%v", err) + } + if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil { + t.Fatalf("expected error") + } +} + +type assertErr struct{} + +func (assertErr) Error() string { return "network timeout" } diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go new file mode 100644 index 00000000..34128a93 --- /dev/null +++ b/shortcuts/base/base_execute_test.go @@ -0,0 +1,1047 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + config := &core.CliConfig{ + AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_testuser", + } + factory, stdout, _, reg := cmdutil.TestFactory(t, config) + return factory, stdout, reg +} + +func registerTokenStub(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, + "tenant_access_token": "t-test-token", + "expire": 7200, + }, + }) +} + +func withBaseWorkingDir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() err=%v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) err=%v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("restore cwd err=%v", err) + } + }) +} + +func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + shortcut.AuthTypes = []string{"bot"} + parent := &cobra.Command{Use: "base"} + shortcut.Mount(parent, factory) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + stdout.Reset() + return parent.ExecuteContext(context.Background()) +} + +func TestBaseWorkspaceExecuteCreate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"app_token": "app_x"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) { + t.Run("get", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"base_token": "app_x", "name": "Demo Base"}, + }, + }) + if err := runShortcut(t, BaseBaseGet, []string{"+base-get", "--base-token", "app_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"base"`) || !strings.Contains(got, `"Demo Base"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("copy", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_src/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base", "url": "https://example.com/base/app_new"}, + }, + }) + args := []string{"+base-copy", "--base-token", "app_src", "--name", "Copied Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai", "--without-content"} + if err := runShortcut(t, BaseBaseCopy, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"copied": true`) || !strings.Contains(got, `"app_new"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseHistoryExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/record_history", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"record_id": "rec_x"}}}, + }, + }) + if err := runShortcut(t, BaseRecordHistoryList, []string{"+record-history-list", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_x", "--page-size", "10"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id": "rec_x"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFieldExecuteUpdate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_x", "name": "Amount", "type": "number"}, + }, + }) + if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseTableExecuteCreate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_new", "name": "Orders"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{map[string]interface{}{"id": "fld_primary", "name": "Primary"}}}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/fields/fld_primary", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_primary", "name": "OrderNo", "type": "text"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/views", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "vew_main", "name": "Main", "type": "grid"}, + }, + }) + args := []string{"+table-create", "--base-token", "app_x", "--name", "Orders", "--fields", `[{"name":"OrderNo","type":"text"}]`, "--view", `{"name":"Main","type":"grid"}`} + if err := runShortcut(t, BaseTableCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"table"`) || !strings.Contains(got, `"vew_main"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseTableExecuteUpdate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_x", "name": "Orders Updated"}, + }, + }) + if err := runShortcut(t, BaseTableUpdate, []string{"+table-update", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "Orders Updated"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"Orders Updated"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseRecordExecuteUpsertUpdate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"record_id": "rec_x", "fields": map[string]interface{}{"Name": "Alice"}}, + }, + }) + if err := runShortcut(t, BaseRecordUpsert, []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_x", "--json", `{"fields":{"Name":"Alice"}}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"rec_x"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseViewExecuteRename(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "vew_x", "name": "Renamed", "type": "grid"}, + }, + }) + if err := runShortcut(t, BaseViewRename, []string{"+view-rename", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--name", "Renamed"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"Renamed"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseViewExecutePropertyActions(t *testing.T) { + t.Run("set-group", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/group", + Body: map[string]interface{}{ + "code": 0, + "data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}}, + }, + }) + if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_status","desc":false}]`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("set-sort", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/sort", + Body: map[string]interface{}{ + "code": 0, + "data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}}, + }, + }) + if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_amount","desc":true}]`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) { + t.Fatalf("stdout=%s", got) + } + }) + +} + +func TestBaseFieldExecuteCRUD(t *testing.T) { + t.Run("list", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{ + map[string]interface{}{"id": "fld_2", "name": "Amount", "type": "number"}, + }, "total": 2}, + }, + }) + if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "tbl_x", "--offset", "0", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"field_name": "Amount"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_x", "name": "Amount", "type": "number"}, + }, + }) + if err := runShortcut(t, BaseFieldGet, []string{"+field-get", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"field"`) || !strings.Contains(got, `"fld_x"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("create", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_new", "name": "Status", "type": "text"}, + }, + }) + if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("delete", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseFieldDelete, []string{"+field-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"field_id": "fld_x"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseTableExecuteReadAndDelete(t *testing.T) { + t.Run("list", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_a", "name": "Alpha"}, + }, "total": 2}, + }, + }) + if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"table_name": "Alpha"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("list-http-404", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Status: 404, + Body: "404 page not found", + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + }, + }) + err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "HTTP 404") || !strings.Contains(err.Error(), "404 page not found") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("get", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_x", "name": "Orders", "primary_field": "fld_x"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{map[string]interface{}{"id": "fld_x", "name": "OrderNo", "type": "text"}}}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"views": []interface{}{map[string]interface{}{"id": "vew_x", "name": "Main", "type": "grid"}}}, + }, + }) + if err := runShortcut(t, BaseTableGet, []string{"+table-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"name": "Orders"`) || !strings.Contains(got, `"primary_field": "fld_x"`) || !strings.Contains(got, `"vew_x"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("delete", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseTableDelete, []string{"+table-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"table_id": "tbl_x"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { + t.Run("list", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"records": map[string]interface{}{ + "schema": []interface{}{"Name", "Age"}, + "record_ids": []interface{}{"rec_1"}, + "rows": []interface{}{[]interface{}{"Alice", 18}}, + }}, + }, + }) + if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"records"`) || !strings.Contains(got, `"Alice"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("list new shape", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "fields": []interface{}{"Name", "Age"}, + "record_id_list": []interface{}{"rec_2"}, + "data": []interface{}{[]interface{}{"Bob", 20}}, + "total": 1, + }, + }, + }) + if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Bob"`) || !strings.Contains(got, `"rec_2"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"records": map[string]interface{}{ + "schema": []interface{}{"Name", "Age"}, + "record_ids": []interface{}{"rec_1"}, + "rows": []interface{}{[]interface{}{"Alice", 18}}, + }}, + }, + }) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_ids"`) || !strings.Contains(got, `"Name"`) || strings.Contains(got, `"raw"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get passthrough fallback", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_2", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"unexpected": "shape", "record_id": "rec_2"}, + }, + }) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"unexpected": "shape"`) || strings.Contains(got, `"raw"`) || strings.Contains(got, `"record":`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("create", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"record_id": "rec_new", "fields": map[string]interface{}{"Name": "Alice"}}, + }, + }) + if err := runShortcut(t, BaseRecordUpsert, []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":{"Name":"Alice"}}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"rec_new"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("delete", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"record_id": "rec_1"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("upload attachment", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + + tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.txt") + if err != nil { + t.Fatalf("CreateTemp() err=%v", err) + } + if _, err := tmpFile.WriteString("hello attachment"); err != nil { + t.Fatalf("WriteString() err=%v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("Close() err=%v", err) + } + withBaseWorkingDir(t, filepath.Dir(tmpFile.Name())) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id": "rec_x", + "fields": map[string]interface{}{ + "附件": []interface{}{ + map[string]interface{}{ + "file_token": "existing_tok", + "name": "existing.pdf", + "size": 2048, + "image_width": 640, + "image_height": 480, + "deprecated_set_attachment": false, + }, + }, + }, + }, + }, + }) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "file_tok_1"}, + }, + } + reg.Register(uploadStub) + updateStub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id": "rec_x", + "fields": map[string]interface{}{ + "附件": []interface{}{ + map[string]interface{}{ + "file_token": "existing_tok", + "name": "existing.pdf", + "size": 2048, + "image_width": 640, + "image_height": 480, + "deprecated_set_attachment": true, + }, + map[string]interface{}{ + "file_token": "file_tok_1", + "name": "report.txt", + "deprecated_set_attachment": true, + }, + }, + }, + }, + }, + } + reg.Register(updateStub) + + if err := runShortcut(t, BaseRecordUploadAttachment, []string{ + "+record-upload-attachment", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--record-id", "rec_x", + "--field-id", "fld_att", + "--file", "./" + filepath.Base(tmpFile.Name()), + "--name", "report.txt", + }, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_1"`) || !strings.Contains(got, `"report.txt"`) { + t.Fatalf("stdout=%s", got) + } + + uploadBody := string(uploadStub.CapturedBody) + if !strings.Contains(uploadBody, `name="parent_type"`) || !strings.Contains(uploadBody, "bitable_file") || !strings.Contains(uploadBody, `name="parent_node"`) || !strings.Contains(uploadBody, "app_x") { + t.Fatalf("upload body=%s", uploadBody) + } + + updateBody := string(updateStub.CapturedBody) + if !strings.Contains(updateBody, `"附件"`) || + !strings.Contains(updateBody, `"file_token":"existing_tok"`) || + !strings.Contains(updateBody, `"name":"existing.pdf"`) || + !strings.Contains(updateBody, `"size":2048`) || + !strings.Contains(updateBody, `"image_width":640`) || + !strings.Contains(updateBody, `"image_height":480`) || + !strings.Contains(updateBody, `"deprecated_set_attachment":true`) || + !strings.Contains(updateBody, `"file_token":"file_tok_1"`) || + !strings.Contains(updateBody, `"name":"report.txt"`) { + t.Fatalf("update body=%s", updateBody) + } + }) + + t.Run("upload attachment rejects non-attachment field", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + + tmpFile, err := os.CreateTemp(t.TempDir(), "base-not-attachment-*.txt") + if err != nil { + t.Fatalf("CreateTemp() err=%v", err) + } + if _, err := tmpFile.WriteString("hello"); err != nil { + t.Fatalf("WriteString() err=%v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("Close() err=%v", err) + } + withBaseWorkingDir(t, filepath.Dir(tmpFile.Name())) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_status", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_status", "name": "状态", "type": "text"}, + }, + }) + + err = runShortcut(t, BaseRecordUploadAttachment, []string{ + "+record-upload-attachment", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--record-id", "rec_x", + "--field-id", "fld_status", + "--file", "./" + filepath.Base(tmpFile.Name()), + }, factory, stdout) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "expected attachment") { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) { + t.Run("list", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"views": []interface{}{map[string]interface{}{"id": "vew_1", "name": "Main", "type": "grid"}}, "total": 3}, + }, + }) + if err := runShortcut(t, BaseViewList, []string{"+view-list", "--base-token", "app_x", "--table-id", "tbl_x", "--offset", "0", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"total": 3`) || !strings.Contains(got, `"view_name": "Main"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "vew_1", "name": "Main", "type": "grid"}, + }, + }) + if err := runShortcut(t, BaseViewGet, []string{"+view-get", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"view"`) || !strings.Contains(got, `"vew_1"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("create", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "vew_1", "name": "Main", "type": "grid"}, + }, + }) + if err := runShortcut(t, BaseViewCreate, []string{"+view-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Main","type":"grid"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"views"`) || !strings.Contains(got, `"vew_1"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("delete", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseViewDelete, []string{"+view-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"view_id": "vew_1"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("set-filter", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1/filter", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"conditions": []interface{}{map[string]interface{}{"field_name": "Status"}}}, + }, + }) + if err := runShortcut(t, BaseViewSetFilter, []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--json", `{"conditions":[{"field_name":"Status"}]}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"filter"`) || !strings.Contains(got, `"Status"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseTableExecuteListFallbackShapes(t *testing.T) { + t.Run("items-payload", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"id": "tbl_items", "name": "ItemsOnly"}}}, + }, + }) + if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"ItemsOnly"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("single-object-payload", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_single", "name": "SingleOnly"}, + }, + }) + if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"SingleOnly"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseRecordExecuteListWithViewPagination(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "view_id=vew_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"records": map[string]interface{}{ + "schema": []interface{}{"Name", "Index"}, + "record_ids": []interface{}{"rec_last"}, + "rows": []interface{}{[]interface{}{"Tail", 200}}, + }, "total": 201}, + }, + }) + if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"rec_last"`) || !strings.Contains(got, `"total": 201`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseHistoryExecuteWithLinkFieldLimit(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "max_version=2", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"record_id": "rec_x", "field_name": "History"}}}, + }, + }) + if err := runShortcut(t, BaseRecordHistoryList, []string{"+record-history-list", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_x", "--page-size", "10", "--max-version", "2"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"field_name": "History"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFieldExecuteSearchOptions(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_amount/options", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"options": []interface{}{map[string]interface{}{"id": "opt_1", "name": "已完成"}}, "total": 1}, + }, + }) + if err := runShortcut(t, BaseFieldSearchOptions, []string{"+field-search-options", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_amount", "--keyword", "已", "--limit", "10"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"options"`) || !strings.Contains(got, `"已完成"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseViewExecutePropertyGettersAndExtendedSetters(t *testing.T) { + t.Run("get-group", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/group", Body: map[string]interface{}{"code": 0, "data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}}}}) + if err := runShortcut(t, BaseViewGetGroup, []string{"+view-get-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get-filter", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/filter", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"conditions": []interface{}{map[string]interface{}{"field_name": "Status"}}}}}) + if err := runShortcut(t, BaseViewGetFilter, []string{"+view-get-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"filter"`) || !strings.Contains(got, `"Status"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get-sort", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/sort", Body: map[string]interface{}{"code": 0, "data": []interface{}{map[string]interface{}{"field": "fld_priority", "desc": true}}}}) + if err := runShortcut(t, BaseViewGetSort, []string{"+view-get-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_priority"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get-timebar", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_time/timebar", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"start_time": "fld_start", "end_time": "fld_end", "title": "fld_title"}}}) + if err := runShortcut(t, BaseViewGetTimebar, []string{"+view-get-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_time"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"timebar"`) || !strings.Contains(got, `"fld_start"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("set-timebar", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_time/timebar", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"start_time": "fld_start", "end_time": "fld_end", "title": "fld_title"}}}) + args := []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_time", "--json", `{"start_time":"fld_start","end_time":"fld_end","title":"fld_title"}`} + if err := runShortcut(t, BaseViewSetTimebar, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"timebar"`) || !strings.Contains(got, `"fld_end"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get-card", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_card/card", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"cover_field": "fld_cover"}}}) + if err := runShortcut(t, BaseViewGetCard, []string{"+view-get-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_card"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"card"`) || !strings.Contains(got, `"fld_cover"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("set-card", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_card/card", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"cover_field": "fld_cover"}}}) + if err := runShortcut(t, BaseViewSetCard, []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_card", "--json", `{"cover_field":"fld_cover"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"card"`) || !strings.Contains(got, `"fld_cover"`) { + t.Fatalf("stdout=%s", got) + } + }) +} diff --git a/shortcuts/base/base_form_create.go b/shortcuts/base/base_form_create.go new file mode 100644 index 00000000..978d2446 --- /dev/null +++ b/shortcuts/base/base_form_create.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormCreate = common.Shortcut{ + Service: "base", + Command: "+form-create", + Description: "Create a form in a Base table", + Risk: "write", + Scopes: []string{"base:form:create"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "name", Desc: "form name", Required: true}, + {Name: "description", Desc: `form description (plain text or markdown link like [text](https://example.com))`}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + name := runtime.Str("name") + description := runtime.Str("description") + + body := map[string]interface{}{"name": name} + if description != "" { + body["description"] = description + } + + data, err := baseV3Call(runtime, "POST", + baseV3Path("bases", baseToken, "tables", tableId, "forms"), nil, body) + if err != nil { + return err + } + + runtime.OutFormat(data, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{ + { + "id": data["id"], + "name": data["name"], + "description": data["description"], + }, + }) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_delete.go b/shortcuts/base/base_form_delete.go new file mode 100644 index 00000000..514e5acf --- /dev/null +++ b/shortcuts/base/base_form_delete.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormDelete = common.Shortcut{ + Service: "base", + Command: "+form-delete", + Description: "Delete a form in a Base table", + Risk: "high-risk-write", + Scopes: []string{"base:form:delete"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base app token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + + _, err := baseV3Call(runtime, "DELETE", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId), nil, nil) + if err != nil { + return err + } + + runtime.Out(map[string]interface{}{"deleted": true, "form_id": formId}, nil) + return nil + }, +} diff --git a/shortcuts/base/base_form_execute_test.go b/shortcuts/base/base_form_execute_test.go new file mode 100644 index 00000000..668a830f --- /dev/null +++ b/shortcuts/base/base_form_execute_test.go @@ -0,0 +1,364 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestBaseFormExecuteList(t *testing.T) { + t.Run("single page", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "total": 2, + "forms": []interface{}{ + map[string]interface{}{"id": "vew_form1", "name": "用户调研问卷", "description": "2024年调研"}, + map[string]interface{}{"id": "vew_form2", "name": "产品反馈表", "description": ""}, + }, + }, + }, + }) + if err := runShortcut(t, BaseFormsList, []string{"+form-list", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"total": 2`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("auto pagination", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + // First page: has_more=true + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": true, + "page_token": "tok_p2", + "total": 2, + "forms": []interface{}{ + map[string]interface{}{"id": "vew_form1", "name": "Page1 Form", "description": ""}, + }, + }, + }, + }) + // Second page: has_more=false + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "page_token=tok_p2", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "total": 2, + "forms": []interface{}{ + map[string]interface{}{"id": "vew_form2", "name": "Page2 Form", "description": ""}, + }, + }, + }, + }) + if err := runShortcut(t, BaseFormsList, []string{"+form-list", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"vew_form2"`) { + t.Fatalf("stdout=%s", got) + } + if !strings.Contains(got, `"total": 2`) { + t.Fatalf("expected total=2 in stdout=%s", got) + } + }) +} + +func TestBaseFormExecuteGet(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form1", + "name": "用户调研问卷", + "description": "2024年度用户满意度调研", + }, + }, + }) + if err := runShortcut(t, BaseFormGet, []string{"+form-get", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"用户调研问卷"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFormExecuteCreate(t *testing.T) { + t.Run("name only", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form_new", + "name": "新建表单", + "description": "", + }, + }, + }) + if err := runShortcut(t, BaseFormCreate, []string{"+form-create", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "新建表单"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form_new"`) || !strings.Contains(got, `"新建表单"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("with description", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form_desc", + "name": "含描述表单", + "description": "这是表单说明", + }, + }, + }) + args := []string{"+form-create", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "含描述表单", + "--description", "这是表单说明"} + if err := runShortcut(t, BaseFormCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form_desc"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("with description link", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form_link", + "name": "含链接表单", + "description": "更多信息请查看[这里](https://example.com)", + }, + }, + }) + args := []string{"+form-create", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "含链接表单", + "--description", "更多信息请查看[这里](https://example.com)"} + if err := runShortcut(t, BaseFormCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form_link"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseFormExecuteUpdate(t *testing.T) { + t.Run("update name", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form1", + "name": "更新后的表单", + "description": "", + }, + }, + }) + if err := runShortcut(t, BaseFormUpdate, []string{"+form-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", "--name", "更新后的表单"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"更新后的表单"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("update with description", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form1", + "name": "Form", + "description": "更新的描述内容", + }, + }, + }) + args := []string{"+form-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--description", "更新的描述内容"} + if err := runShortcut(t, BaseFormUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseFormExecuteDelete(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseFormDelete, []string{"+form-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"form_id": "vew_form1"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFormQuestionsExecuteList(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "total": 2, + "questions": []interface{}{ + map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil}, + map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil}, + }, + }, + }, + }) + if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFormQuestionsExecuteCreate(t *testing.T) { + t.Run("create questions", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "questions": []interface{}{ + map[string]interface{}{"id": "q_new1", "title": "您的姓名", "required": true}, + }, + }, + }, + }) + args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--questions", `[{"type":"text","title":"您的姓名","required":true}]`} + if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_new1"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("invalid questions json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--questions", `not-an-array`} + if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err == nil { + t.Fatalf("expected error for invalid questions JSON") + } + }) +} + +func TestBaseFormQuestionsExecuteUpdate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "questions": []interface{}{ + map[string]interface{}{"id": "q_001", "title": "更新后的问题", "required": true}, + }, + }, + }, + }) + args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`} + if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFormQuestionsExecuteDelete(t *testing.T) { + t.Run("delete questions", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + args := []string{"+form-questions-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--question-ids", `["q_001","q_002"]`, "--yes"} + if err := runShortcut(t, BaseFormQuestionsDelete, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"q_001"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("invalid question-ids json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+form-questions-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--question-ids", `not-json`} + if err := runShortcut(t, BaseFormQuestionsDelete, args, factory, stdout); err == nil { + t.Fatalf("expected error for invalid question-ids JSON") + } + }) +} diff --git a/shortcuts/base/base_form_get.go b/shortcuts/base/base_form_get.go new file mode 100644 index 00000000..ca44dac4 --- /dev/null +++ b/shortcuts/base/base_form_get.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormGet = common.Shortcut{ + Service: "base", + Command: "+form-get", + Description: "Get a form in a Base table", + Risk: "read", + Scopes: []string{"base:form:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base app token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + + data, err := baseV3Call(runtime, "GET", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId), nil, nil) + if err != nil { + return err + } + + runtime.OutFormat(data, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{ + { + "id": data["id"], + "name": data["name"], + "description": data["description"], + }, + }) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_list.go b/shortcuts/base/base_form_list.go new file mode 100644 index 00000000..07ead6e5 --- /dev/null +++ b/shortcuts/base/base_form_list.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormsList = common.Shortcut{ + Service: "base", + Command: "+form-list", + Description: "List all forms in a Base table (auto-paginated)", + Risk: "read", + Scopes: []string{"base:form:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + + var allForms []interface{} + pageToken := "" + for { + params := map[string]interface{}{ + "page_size": runtime.Int("page-size"), + } + if pageToken != "" { + params["page_token"] = pageToken + } + + data, err := baseV3Call(runtime, "GET", + baseV3Path("bases", baseToken, "tables", tableId, "forms"), params, nil) + if err != nil { + return err + } + + forms, _ := data["forms"].([]interface{}) + allForms = append(allForms, forms...) + + hasMore, _ := data["has_more"].(bool) + if !hasMore { + break + } + nextToken, _ := data["page_token"].(string) + if nextToken == "" { + break + } + pageToken = nextToken + } + + outData := map[string]interface{}{ + "forms": allForms, + "total": len(allForms), + } + runtime.OutFormat(outData, nil, func(w io.Writer) { + if len(allForms) == 0 { + fmt.Fprintln(w, "No forms found.") + return + } + var rows []map[string]interface{} + for _, item := range allForms { + m, _ := item.(map[string]interface{}) + rows = append(rows, map[string]interface{}{ + "id": m["id"], + "name": m["name"], + "description": m["description"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d form(s) total\n", len(allForms)) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_questions_create.go b/shortcuts/base/base_form_questions_create.go new file mode 100644 index 00000000..c9c79642 --- /dev/null +++ b/shortcuts/base/base_form_questions_create.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormQuestionsCreate = common.Shortcut{ + Service: "base", + Command: "+form-questions-create", + Description: "Create questions for a form in a Base table", + Risk: "write", + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + {Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + questionsJSON := runtime.Str("questions") + + var questions []interface{} + if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil { + return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err) + } + + data, err := baseV3Call(runtime, "POST", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId, "questions"), + nil, map[string]interface{}{"questions": questions}) + if err != nil { + return err + } + + items, _ := data["questions"].([]interface{}) + outData := map[string]interface{}{"questions": items} + + runtime.OutFormat(outData, nil, func(w io.Writer) { + var rows []map[string]interface{} + for _, item := range items { + m, _ := item.(map[string]interface{}) + rows = append(rows, map[string]interface{}{ + "id": m["id"], + "title": m["title"], + "required": m["required"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d question(s) created\n", len(items)) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_questions_delete.go b/shortcuts/base/base_form_questions_delete.go new file mode 100644 index 00000000..4a3b0387 --- /dev/null +++ b/shortcuts/base/base_form_questions_delete.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormQuestionsDelete = common.Shortcut{ + Service: "base", + Command: "+form-questions-delete", + Description: "Delete questions from a form in a Base table", + Risk: "high-risk-write", + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + {Name: "question-ids", Desc: `JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`, Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + questionIdsJSON := runtime.Str("question-ids") + + var questionIds []string + if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil { + return output.Errorf(output.ExitValidation, "invalid_json", "--question-ids must be a valid JSON array of strings: %s", err) + } + + _, err := baseV3Call(runtime, "DELETE", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId, "questions"), + nil, map[string]interface{}{"question_ids": questionIds}) + if err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "deleted": true, + "question_ids": questionIds, + }, nil) + return nil + }, +} diff --git a/shortcuts/base/base_form_questions_list.go b/shortcuts/base/base_form_questions_list.go new file mode 100644 index 00000000..ad73c7b4 --- /dev/null +++ b/shortcuts/base/base_form_questions_list.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormQuestionsList = common.Shortcut{ + Service: "base", + Command: "+form-questions-list", + Description: "List questions of a form in a Base table", + Risk: "read", + Scopes: []string{"base:form:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base app token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + + data, err := baseV3Call(runtime, "GET", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId, "questions"), nil, nil) + if err != nil { + return err + } + + items, _ := data["questions"].([]interface{}) + outData := map[string]interface{}{ + "questions": items, + "total": data["total"], + } + + runtime.OutFormat(outData, nil, func(w io.Writer) { + if len(items) == 0 { + fmt.Fprintln(w, "No questions found.") + return + } + var rows []map[string]interface{} + for _, item := range items { + m, _ := item.(map[string]interface{}) + rows = append(rows, map[string]interface{}{ + "id": m["id"], + "title": m["title"], + "description": m["description"], + "required": m["required"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%v question(s) total\n", data["total"]) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_questions_update.go b/shortcuts/base/base_form_questions_update.go new file mode 100644 index 00000000..1abc35be --- /dev/null +++ b/shortcuts/base/base_form_questions_update.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormQuestionsUpdate = common.Shortcut{ + Service: "base", + Command: "+form-questions-update", + Description: "Update questions of a form in a Base table", + Risk: "write", + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + {Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + questionsJSON := runtime.Str("questions") + + var questions []interface{} + if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil { + return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err) + } + + data, err := baseV3Call(runtime, "PATCH", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId, "questions"), + nil, map[string]interface{}{"questions": questions}) + if err != nil { + return err + } + + items, _ := data["items"].([]interface{}) + if len(items) == 0 { + items, _ = data["questions"].([]interface{}) + } + outData := map[string]interface{}{"questions": items} + + runtime.OutFormat(outData, nil, func(w io.Writer) { + var rows []map[string]interface{} + for _, item := range items { + m, _ := item.(map[string]interface{}) + rows = append(rows, map[string]interface{}{ + "id": m["id"], + "title": m["title"], + "required": m["required"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d question(s) updated\n", len(items)) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_update.go b/shortcuts/base/base_form_update.go new file mode 100644 index 00000000..53096e20 --- /dev/null +++ b/shortcuts/base/base_form_update.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormUpdate = common.Shortcut{ + Service: "base", + Command: "+form-update", + Description: "Update a form in a Base table", + Risk: "write", + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + {Name: "name", Desc: "new form name"}, + {Name: "description", Desc: "new form description (plain text or markdown link like [text](https://example.com))"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + name := runtime.Str("name") + description := runtime.Str("description") + + body := map[string]interface{}{} + if name != "" { + body["name"] = name + } + if description != "" { + body["description"] = description + } + + data, err := baseV3Call(runtime, "PATCH", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId), nil, body) + if err != nil { + return err + } + + runtime.OutFormat(data, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{ + { + "id": data["id"], + "name": data["name"], + "description": data["description"], + }, + }) + }) + return nil + }, +} diff --git a/shortcuts/base/base_get.go b/shortcuts/base/base_get.go new file mode 100644 index 00000000..48e7080e --- /dev/null +++ b/shortcuts/base/base_get.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseGet = common.Shortcut{ + Service: "base", + Command: "+base-get", + Description: "Get a base resource", + Risk: "read", + Scopes: []string{"base:app:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true)}, + DryRun: dryRunBaseGet, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseGet(runtime) + }, +} diff --git a/shortcuts/base/base_ops.go b/shortcuts/base/base_ops.go new file mode 100644 index 00000000..8a70cba7 --- /dev/null +++ b/shortcuts/base/base_ops.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token"). + Set("base_token", runtime.Str("base-token")) +} + +func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { + body["folder_token"] = folderToken + } + if runtime.Bool("without-content") { + body["without_content"] = true + } + if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { + body["time_zone"] = timeZone + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/copy"). + Body(body). + Set("base_token", runtime.Str("base-token")) +} + +func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{"name": runtime.Str("name")} + if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { + body["folder_token"] = folderToken + } + if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { + body["time_zone"] = timeZone + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases"). + Body(body) +} + +func executeBaseGet(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"base": data}, nil) + return nil +} + +func executeBaseCopy(runtime *common.RuntimeContext) error { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { + body["folder_token"] = folderToken + } + if runtime.Bool("without-content") { + body["without_content"] = true + } + if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { + body["time_zone"] = timeZone + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"base": data, "copied": true}, nil) + return nil +} + +func executeBaseCreate(runtime *common.RuntimeContext) error { + body := map[string]interface{}{"name": runtime.Str("name")} + if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { + body["folder_token"] = folderToken + } + if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { + body["time_zone"] = timeZone + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"base": data, "created": true}, nil) + return nil +} diff --git a/shortcuts/base/base_role_common.go b/shortcuts/base/base_role_common.go new file mode 100644 index 00000000..ab219091 --- /dev/null +++ b/shortcuts/base/base_role_common.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// handleRoleResponse parses the role API response. +// The response has two layers of code/message: +// - Outer: SDK-level code/msg (handled by DoAPI for transport errors) +// - Inner: business-level code/message inside the data object +// +// The data field may be a JSON object (actual behavior) or a JSON string (per doc). +func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action string) error { + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rawBody, &resp); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + if resp.Code != 0 { + msg := resp.Msg + // When outer msg is empty, try to extract error details from data.error.message + if msg == "" && len(resp.Data) > 0 { + var errData struct { + Error struct { + Message string `json:"message"` + Hint string `json:"hint"` + } `json:"error"` + } + if json.Unmarshal(resp.Data, &errData) == nil && errData.Error.Message != "" { + msg = errData.Error.Message + } + } + return output.ErrAPI(resp.Code, fmt.Sprintf("%s: [%d] %s", action, resp.Code, msg), nil) + } + + if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` { + runtime.Out(map[string]any{"success": true}, nil) + return nil + } + + // Parse data + var data any + if err := json.Unmarshal(resp.Data, &data); err != nil { + runtime.Out(map[string]any{"data": string(resp.Data)}, nil) + return nil + } + + // If data is a string (double-encoded JSON), try to parse it + if s, ok := data.(string); ok && s != "" { + var inner any + if err := json.Unmarshal([]byte(s), &inner); err == nil { + data = inner + } + } + + // Check for business-level error: data may contain its own code/message + if m, ok := data.(map[string]any); ok { + if code, exists := m["code"]; exists { + var codeInt int + switch v := code.(type) { + case float64: + codeInt = int(v) + case int: + codeInt = v + } + if codeInt != 0 { + msg, _ := m["message"].(string) + return output.ErrAPI(codeInt, fmt.Sprintf("%s: [%d] %s", action, codeInt, msg), nil) + } + // code == 0, extract the inner data if present + if innerData, hasInner := m["data"]; hasInner { + // Inner data might be a double-encoded JSON string + if s, ok := innerData.(string); ok && s != "" { + var parsed any + if err := json.Unmarshal([]byte(s), &parsed); err == nil { + runtime.Out(parsed, nil) + return nil + } + } + runtime.Out(innerData, nil) + return nil + } + runtime.Out(map[string]any{"success": true}, nil) + return nil + } + } + + runtime.Out(data, nil) + return nil +} diff --git a/shortcuts/base/base_role_create.go b/shortcuts/base/base_role_create.go new file mode 100644 index 00000000..18b4cb2d --- /dev/null +++ b/shortcuts/base/base_role_create.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRoleCreate = common.Shortcut{ + Service: "base", + Command: "+role-create", + Description: "Create a custom role in a Base", + Risk: "write", + Scopes: []string{"base:role:create"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "json", Desc: `body JSON (AdvPermBaseRoleConfig), e.g. {"role_name":"Reviewer","role_type":"custom_role","table_rule_map":{...}}`, Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + var body map[string]any + if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil { + return common.FlagErrorf("--json must be valid JSON: %v", err) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + var body map[string]any + json.Unmarshal([]byte(runtime.Str("json")), &body) + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/roles"). + Body(body). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + var body map[string]any + json.Unmarshal([]byte(runtime.Str("json")), &body) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/roles", validate.EncodePathSegment(baseToken)), + Body: body, + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "create role failed") + }, +} diff --git a/shortcuts/base/base_role_delete.go b/shortcuts/base/base_role_delete.go new file mode 100644 index 00000000..0c5627fc --- /dev/null +++ b/shortcuts/base/base_role_delete.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRoleDelete = common.Shortcut{ + Service: "base", + Command: "+role-delete", + Description: "Delete a custom role (system roles cannot be deleted)", + Risk: "high-risk-write", + Scopes: []string{"base:role:delete"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("role-id")) == "" { + return common.FlagErrorf("--role-id must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/roles/:role_id"). + Set("base_token", runtime.Str("base-token")). + Set("role_id", runtime.Str("role-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + roleId := runtime.Str("role-id") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodDelete, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/roles/%s", validate.EncodePathSegment(baseToken), validate.EncodePathSegment(roleId)), + Body: map[string]any{}, + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "delete role failed") + }, +} diff --git a/shortcuts/base/base_role_get.go b/shortcuts/base/base_role_get.go new file mode 100644 index 00000000..ada1c994 --- /dev/null +++ b/shortcuts/base/base_role_get.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRoleGet = common.Shortcut{ + Service: "base", + Command: "+role-get", + Description: "Get full config of a role", + Risk: "read", + Scopes: []string{"base:role:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("role-id")) == "" { + return common.FlagErrorf("--role-id must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/roles/:role_id"). + Set("base_token", runtime.Str("base-token")). + Set("role_id", runtime.Str("role-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + roleId := runtime.Str("role-id") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/roles/%s", validate.EncodePathSegment(baseToken), validate.EncodePathSegment(roleId)), + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "get role failed") + }, +} diff --git a/shortcuts/base/base_role_list.go b/shortcuts/base/base_role_list.go new file mode 100644 index 00000000..08bc2934 --- /dev/null +++ b/shortcuts/base/base_role_list.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRoleList = common.Shortcut{ + Service: "base", + Command: "+role-list", + Description: "List all roles in a Base", + Risk: "read", + Scopes: []string{"base:role:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/roles"). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/roles", validate.EncodePathSegment(baseToken)), + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "list roles failed") + }, +} diff --git a/shortcuts/base/base_role_test.go b/shortcuts/base/base_role_test.go new file mode 100644 index 00000000..d71e4c5d --- /dev/null +++ b/shortcuts/base/base_role_test.go @@ -0,0 +1,608 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// Validate tests +// --------------------------------------------------------------------------- + +func TestBaseRoleCreateValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "", "json": `{"role_name":"R"}`}, nil, nil) + if err := BaseRoleCreate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("whitespace base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": " ", "json": `{"role_name":"R"}`}, nil, nil) + if err := BaseRoleCreate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("invalid json", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "json": "{"}, nil, nil) + if err := BaseRoleCreate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--json must be valid JSON") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "json": `{"role_name":"Reviewer","role_type":"custom_role"}`}, nil, nil) + if err := BaseRoleCreate.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseRoleDeleteValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "", "role-id": "rol_1"}, nil, nil) + if err := BaseRoleDelete.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("blank role-id", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": ""}, nil, nil) + if err := BaseRoleDelete.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--role-id must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("whitespace role-id", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": " "}, nil, nil) + if err := BaseRoleDelete.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--role-id must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1"}, nil, nil) + if err := BaseRoleDelete.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseRoleGetValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "", "role-id": "rol_1"}, nil, nil) + if err := BaseRoleGet.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("blank role-id", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": ""}, nil, nil) + if err := BaseRoleGet.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--role-id must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1"}, nil, nil) + if err := BaseRoleGet.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseRoleListValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": ""}, nil, nil) + if err := BaseRoleList.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + if err := BaseRoleList.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseRoleUpdateValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "", "role-id": "rol_1", "json": `{"role_name":"X"}`}, nil, nil) + if err := BaseRoleUpdate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("blank role-id", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "", "json": `{"role_name":"X"}`}, nil, nil) + if err := BaseRoleUpdate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--role-id must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("invalid json", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1", "json": "["}, nil, nil) + if err := BaseRoleUpdate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--json must be valid JSON") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1", "json": `{"role_name":"New Name"}`}, nil, nil) + if err := BaseRoleUpdate.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +// --------------------------------------------------------------------------- +// DryRun tests +// --------------------------------------------------------------------------- + +func TestBaseRoleCreateDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "json": `{"role_name":"Reviewer"}`}, nil, nil) + dr := BaseRoleCreate.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestBaseRoleDeleteDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1"}, nil, nil) + dr := BaseRoleDelete.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestBaseRoleGetDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1"}, nil, nil) + dr := BaseRoleGet.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestBaseRoleListDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + dr := BaseRoleList.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestBaseRoleUpdateDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1", "json": `{"role_name":"New"}`}, nil, nil) + dr := BaseRoleUpdate.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +// --------------------------------------------------------------------------- +// Shortcut metadata tests +// --------------------------------------------------------------------------- + +func TestBaseRoleShortcutMetadata(t *testing.T) { + tests := []struct { + name string + s common.Shortcut + command string + risk string + scopes []string + }{ + {"create", BaseRoleCreate, "+role-create", "write", []string{"base:role:create"}}, + {"delete", BaseRoleDelete, "+role-delete", "high-risk-write", []string{"base:role:delete"}}, + {"get", BaseRoleGet, "+role-get", "read", []string{"base:role:read"}}, + {"list", BaseRoleList, "+role-list", "read", []string{"base:role:read"}}, + {"update", BaseRoleUpdate, "+role-update", "high-risk-write", []string{"base:role:update"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.s.Command != tt.command { + t.Fatalf("command=%q want=%q", tt.s.Command, tt.command) + } + if tt.s.Risk != tt.risk { + t.Fatalf("risk=%q want=%q", tt.s.Risk, tt.risk) + } + if tt.s.Service != "base" { + t.Fatalf("service=%q", tt.s.Service) + } + if len(tt.s.Scopes) != len(tt.scopes) || tt.s.Scopes[0] != tt.scopes[0] { + t.Fatalf("scopes=%v want=%v", tt.s.Scopes, tt.scopes) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Execute tests (with httpmock) +// --------------------------------------------------------------------------- + +func TestBaseRoleCreateExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"role_id": "rol_new", "role_name": "Reviewer"}, + }, + }, + }) + args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Reviewer","role_type":"custom_role"}`} + if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "rol_new") || !strings.Contains(got, "Reviewer") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseRoleDeleteExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": nil, + }, + }) + args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"} + if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "success") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseRoleGetExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "role_id": "rol_1", + "role_name": "Admin", + "role_type": "system_role", + }, + }, + }, + }) + args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_1"} + if err := runShortcut(t, BaseRoleGet, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "rol_1") || !strings.Contains(got, "Admin") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseRoleListExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "code": 0, + "data": []interface{}{ + map[string]interface{}{"role_id": "rol_1", "role_name": "Admin"}, + map[string]interface{}{"role_id": "rol_2", "role_name": "Viewer"}, + }, + }, + }, + }) + args := []string{"+role-list", "--base-token", "app_x"} + if err := runShortcut(t, BaseRoleList, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "rol_1") || !strings.Contains(got, "rol_2") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseRoleUpdateExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"role_id": "rol_1", "role_name": "Editor"}, + }, + }, + }) + args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"Editor"}`, "--yes"} + if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "rol_1") || !strings.Contains(got, "Editor") { + t.Fatalf("stdout=%s", got) + } +} + +// --------------------------------------------------------------------------- +// Execute error paths +// --------------------------------------------------------------------------- + +func TestBaseRoleCreateExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/roles", + Body: map[string]interface{}{ + "code": 190001, + "msg": "bad request", + }, + }) + args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`} + if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseRoleListExecuteTransportError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/roles", + Status: 500, + Body: "internal server error", + }) + args := []string{"+role-list", "--base-token", "app_x"} + if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil { + t.Fatalf("expected transport error") + } +} + +func TestBaseRoleListExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/roles", + Body: map[string]interface{}{ + "code": 190002, + "msg": "not found", + }, + }) + args := []string{"+role-list", "--base-token", "app_x"} + if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseRoleDeleteExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_1", + Body: map[string]interface{}{ + "code": 190003, + "msg": "forbidden", + }, + }) + args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"} + if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190003") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseRoleUpdateExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_1", + Body: map[string]interface{}{ + "code": 190004, + "msg": "invalid params", + }, + }) + args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"} + if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190004") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseRoleGetExecuteBusinessError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_bad", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "code": 100001, + "message": "role not found", + }, + }, + }) + args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"} + if err := runShortcut(t, BaseRoleGet, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "100001") || !strings.Contains(err.Error(), "role not found") { + t.Fatalf("err=%v", err) + } +} + +// --------------------------------------------------------------------------- +// handleRoleResponse unit tests +// --------------------------------------------------------------------------- + +func newRoleResponseRuntime(t *testing.T) *common.RuntimeContext { + t.Helper() + factory, _, _ := newExecuteFactory(t) + cfg, _ := factory.Config() + return &common.RuntimeContext{ + Cmd: &cobra.Command{Use: "test"}, + Config: cfg, + Factory: factory, + } +} + +func TestHandleRoleResponse(t *testing.T) { + t.Run("invalid json", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + if err := handleRoleResponse(rt, []byte("{bad"), "test"); err == nil || !strings.Contains(err.Error(), "failed to parse response") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("outer error code", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + if err := handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"); err == nil || !strings.Contains(err.Error(), "999") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":1,"data":{"error":{"hint":"failed to update","message":"the name already exists!","type":""}},"msg":""}` + err := handleRoleResponse(rt, []byte(body), "test") + if err == nil || !strings.Contains(err.Error(), "the name already exists!") { + t.Fatalf("err=%v, want error containing 'the name already exists!'", err) + } + }) + + t.Run("outer error code with empty msg and no data error", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":2,"data":{},"msg":""}` + err := handleRoleResponse(rt, []byte(body), "test") + if err == nil || !strings.Contains(err.Error(), "[2]") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("null data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + if err := handleRoleResponse(rt, []byte(`{"code":0,"msg":"ok","data":null}`), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("empty string data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + if err := handleRoleResponse(rt, []byte(`{"code":0,"msg":"ok","data":""}`), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("empty data field", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + if err := handleRoleResponse(rt, []byte(`{"code":0,"msg":"ok"}`), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("double encoded json string", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":"{\"role_id\":\"rol_1\"}"}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("non-parseable string data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":"just a plain string"}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("business code zero with inner data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":{"code":0,"data":{"role_id":"rol_1"}}}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("business code zero with double-encoded inner data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":{"code":0,"data":"{\"role_id\":\"rol_1\"}"}}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("business code zero without inner data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":{"code":0,"message":"ok"}}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("business code non-zero", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":{"code":50001,"message":"permission denied"}}` + if err := handleRoleResponse(rt, []byte(body), "test"); err == nil || !strings.Contains(err.Error(), "50001") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("data is array", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":[{"role_id":"rol_1"},{"role_id":"rol_2"}]}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("data is object without code field", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":{"role_id":"rol_1","role_name":"Admin"}}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) +} diff --git a/shortcuts/base/base_role_update.go b/shortcuts/base/base_role_update.go new file mode 100644 index 00000000..8c05c4d8 --- /dev/null +++ b/shortcuts/base/base_role_update.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRoleUpdate = common.Shortcut{ + Service: "base", + Command: "+role-update", + Description: "Update a role config (delta merge, only changed fields needed)", + Risk: "high-risk-write", + Scopes: []string{"base:role:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, + {Name: "json", Desc: `body JSON (delta AdvPermBaseRoleConfig), e.g. {"role_name":"New Name","role_type":"custom_role","table_rule_map":{...}}`, Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("role-id")) == "" { + return common.FlagErrorf("--role-id must not be blank") + } + var body map[string]any + if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil { + return common.FlagErrorf("--json must be valid JSON: %v", err) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + var body map[string]any + json.Unmarshal([]byte(runtime.Str("json")), &body) + return common.NewDryRunAPI(). + Desc("Delta merge: only changed fields are updated, others remain unchanged"). + PUT("/open-apis/base/v3/bases/:base_token/roles/:role_id"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("role_id", runtime.Str("role-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + roleId := runtime.Str("role-id") + var body map[string]any + json.Unmarshal([]byte(runtime.Str("json")), &body) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPut, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/roles/%s", validate.EncodePathSegment(baseToken), validate.EncodePathSegment(roleId)), + Body: body, + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "update role failed") + }, +} diff --git a/shortcuts/base/base_shortcut_helpers.go b/shortcuts/base/base_shortcut_helpers.go new file mode 100644 index 00000000..86e30713 --- /dev/null +++ b/shortcuts/base/base_shortcut_helpers.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func baseTableID(runtime *common.RuntimeContext) string { + return strings.TrimSpace(runtime.Str("table-id")) +} + +func loadJSONInput(raw string, flagName string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", common.FlagErrorf("--%s cannot be empty", flagName) + } + if !strings.HasPrefix(raw, "@") { + return raw, nil + } + path := strings.TrimSpace(strings.TrimPrefix(raw, "@")) + if path == "" { + return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName) + } + safePath, err := validate.SafeInputPath(path) + if err != nil { + return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, err) + } + data, err := os.ReadFile(safePath) + if err != nil { + return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err) + } + content := strings.TrimSpace(string(data)) + if content == "" { + return "", common.FlagErrorf("--%s JSON file %q is empty", flagName, path) + } + return content, nil +} + +func jsonInputTip(flagName string) string { + return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName) +} + +func formatJSONError(flagName string, target string, err error) error { + if syntaxErr, ok := err.(*json.SyntaxError); ok { + return common.FlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName)) + } + if typeErr, ok := err.(*json.UnmarshalTypeError); ok { + if typeErr.Field != "" { + return common.FlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName)) + } + return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName)) + } + return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName)) +} + +func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags []string) (string, error) { + active := []string{} + for _, name := range boolFlags { + if runtime.Bool(name) { + active = append(active, name) + } + } + for _, name := range stringFlags { + if strings.TrimSpace(runtime.Str(name)) != "" { + active = append(active, name) + } + } + if len(active) == 0 { + return "", common.FlagErrorf("specify one action") + } + if len(active) > 1 { + flags := make([]string, 0, len(active)) + for _, item := range active { + flags = append(flags, "--"+item) + } + return "", common.FlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", ")) + } + return active[0], nil +} + +func parseObjectList(raw string, flagName string) ([]map[string]interface{}, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var err error + raw, err = loadJSONInput(raw, flagName) + if err != nil { + return nil, err + } + if strings.HasPrefix(raw, "[") { + arr, err := parseJSONArray(raw, flagName) + if err != nil { + return nil, err + } + items := make([]map[string]interface{}, 0, len(arr)) + for idx, item := range arr { + obj, ok := item.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("--%s item %d must be an object", flagName, idx+1) + } + items = append(items, obj) + } + return items, nil + } + obj, err := parseJSONObject(raw, flagName) + if err != nil { + return nil, err + } + return []map[string]interface{}{obj}, nil +} + +func parseJSONValue(raw string, flagName string) (interface{}, error) { + var err error + raw, err = loadJSONInput(raw, flagName) + if err != nil { + return nil, err + } + var value interface{} + if err := common.ParseJSON([]byte(raw), &value); err != nil { + return nil, formatJSONError(flagName, "value", err) + } + switch value.(type) { + case map[string]interface{}, []interface{}: + return value, nil + default: + return nil, common.FlagErrorf("--%s must be a JSON object or array", flagName) + } +} diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go new file mode 100644 index 00000000..a6f1c61d --- /dev/null +++ b/shortcuts/base/base_shortcuts_test.go @@ -0,0 +1,260 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" +) + +func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext { + cmd := &cobra.Command{Use: "test"} + for name := range stringFlags { + cmd.Flags().String(name, "", "") + } + for name := range boolFlags { + cmd.Flags().Bool(name, false, "") + } + for name := range intFlags { + cmd.Flags().Int(name, 0, "") + } + _ = cmd.ParseFlags(nil) + for name, value := range stringFlags { + _ = cmd.Flags().Set(name, value) + } + for name, value := range boolFlags { + if value { + _ = cmd.Flags().Set(name, "true") + } + } + for name, value := range intFlags { + _ = cmd.Flags().Set(name, strconv.Itoa(value)) + } + return &common.RuntimeContext{Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}} +} + +func TestBaseAction(t *testing.T) { + t.Run("missing action", func(t *testing.T) { + runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": false}, nil) + _, err := baseAction(runtime, []string{"list"}, []string{"get"}) + if err == nil || !strings.Contains(err.Error(), "specify one action") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("single bool action", func(t *testing.T) { + runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": true}, nil) + action, err := baseAction(runtime, []string{"list"}, []string{"get"}) + if err != nil || action != "list" { + t.Fatalf("action=%q err=%v", action, err) + } + }) + + t.Run("mutually exclusive", func(t *testing.T) { + runtime := newBaseTestRuntime(map[string]string{"get": "tbl_1"}, map[string]bool{"list": true}, nil) + _, err := baseAction(runtime, []string{"list"}, []string{"get"}) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err=%v", err) + } + }) +} + +func TestParseObjectList(t *testing.T) { + items, err := parseObjectList("", "view") + if err != nil || items != nil { + t.Fatalf("items=%v err=%v", items, err) + } + + items, err = parseObjectList(`{"name":"grid"}`, "view") + if err != nil || len(items) != 1 || items[0]["name"] != "grid" { + t.Fatalf("items=%v err=%v", items, err) + } + + items, err = parseObjectList(`[{"name":"grid"}]`, "view") + if err != nil || len(items) != 1 || items[0]["name"] != "grid" { + t.Fatalf("items=%v err=%v", items, err) + } + + _, err = parseObjectList(`[1]`, "view") + if err == nil || !strings.Contains(err.Error(), "must be an object") { + t.Fatalf("err=%v", err) + } +} + +func TestWrapViewPropertyBody(t *testing.T) { + arr := []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}} + wrapped := wrapViewPropertyBody(arr, "group_config") + wrappedMap, ok := wrapped.(map[string]interface{}) + if !ok { + t.Fatalf("wrapped type=%T", wrapped) + } + if !reflect.DeepEqual(wrappedMap["group_config"], arr) { + t.Fatalf("wrapped group_config=%v want=%v", wrappedMap["group_config"], arr) + } + + obj := map[string]interface{}{"group_config": arr} + if got := wrapViewPropertyBody(obj, "group_config"); !reflect.DeepEqual(got, obj) { + t.Fatalf("got=%v want=%v", got, obj) + } +} + +func TestShortcutsCatalog(t *testing.T) { + shortcuts := Shortcuts() + want := []string{ + "+table-list", "+table-get", "+table-create", "+table-update", "+table-delete", + "+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options", + "+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename", + "+record-list", "+record-get", "+record-upsert", "+record-upload-attachment", "+record-delete", + "+record-history-list", + "+base-get", "+base-copy", "+base-create", + "+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable", + "+workflow-list", "+workflow-get", "+workflow-create", "+workflow-update", "+workflow-enable", "+workflow-disable", + "+data-query", + "+form-create", "+form-delete", "+form-list", "+form-update", "+form-get", + "+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list", + "+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", + "+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete", + } + if len(shortcuts) != len(want) { + t.Fatalf("len(shortcuts)=%d want=%d", len(shortcuts), len(want)) + } + for index, command := range want { + if shortcuts[index].Command != command { + t.Fatalf("command[%d]=%q want=%q", index, shortcuts[index].Command, command) + } + } +} + +func TestShortcutsDryRunCoverage(t *testing.T) { + for _, shortcut := range Shortcuts() { + if shortcut.DryRun == nil { + t.Fatalf("shortcut %q missing DryRun", shortcut.Command) + } + } +} + +func TestBaseTableDeleteRisk(t *testing.T) { + if BaseTableDelete.Risk != "high-risk-write" { + t.Fatalf("risk=%q want=%q", BaseTableDelete.Risk, "high-risk-write") + } +} + +func TestBaseDeleteShortcutsRisk(t *testing.T) { + cases := map[string]string{ + BaseFieldDelete.Command: BaseFieldDelete.Risk, + BaseViewDelete.Command: BaseViewDelete.Risk, + BaseRecordDelete.Command: BaseRecordDelete.Risk, + BaseFormDelete.Command: BaseFormDelete.Risk, + BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk, + BaseDashboardDelete.Command: BaseDashboardDelete.Risk, + BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk, + BaseRoleDelete.Command: BaseRoleDelete.Risk, + } + + for command, risk := range cases { + if risk != "high-risk-write" { + t.Fatalf("command=%q risk=%q want=%q", command, risk, "high-risk-write") + } + } +} + +func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) { + parent := &cobra.Command{Use: "base"} + BaseFieldCreate.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + if cmd.Flags().Lookup("i-have-read-guide") == nil { + t.Fatalf("flag i-have-read-guide must exist for runtime validation") + } + if strings.Contains(cmd.Flags().FlagUsages(), "--i-have-read-guide") { + t.Fatalf("help should not include --i-have-read-guide") + } +} + +func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) { + parent := &cobra.Command{Use: "base"} + BaseFieldUpdate.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + if cmd.Flags().Lookup("i-have-read-guide") == nil { + t.Fatalf("flag i-have-read-guide must exist for runtime validation") + } + if strings.Contains(cmd.Flags().FlagUsages(), "--i-have-read-guide") { + t.Fatalf("help should not include --i-have-read-guide") + } +} + +func TestBaseFieldValidate(t *testing.T) { + ctx := context.Background() + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err != nil { + t.Fatalf("invalid json should bypass CLI validate, err=%v", err) + } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { + t.Fatalf("err=%v", err) + } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"lookup"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { + t.Fatalf("err=%v", err) + } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil { + t.Fatalf("formula create validate err=%v", err) + } + if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"Amount"}`}, nil, nil)); err != nil { + t.Fatalf("update validate err=%v", err) + } + if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { + t.Fatalf("err=%v", err) + } + if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"lookup"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { + t.Fatalf("err=%v", err) + } + if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil { + t.Fatalf("formula update validate err=%v", err) + } +} + +func TestBaseTableValidate(t *testing.T) { + ctx := context.Background() + if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": "{"}, nil, nil)); err != nil { + t.Fatalf("invalid fields json should bypass CLI validate, err=%v", err) + } + if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "view": `[1]`}, nil, nil)); err != nil { + t.Fatalf("invalid view json should bypass CLI validate, err=%v", err) + } + if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": `[{"name":"Name","type":"text"}]`, "view": `{"name":"Main"}`}, nil, nil)); err != nil { + t.Fatalf("create validate err=%v", err) + } +} + +func TestBaseRecordValidate(t *testing.T) { + ctx := context.Background() + if BaseRecordList.Validate != nil { + t.Fatalf("record list validate should be nil after removing --fields") + } + if BaseRecordGet.Validate != nil { + t.Fatalf("record get validate should be nil after removing --fields") + } + if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"A"}`}, nil, nil)); err != nil { + t.Fatalf("upsert validate err=%v", err) + } + if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": "{"}, nil, nil)); err != nil { + t.Fatalf("invalid record json should bypass CLI validate, err=%v", err) + } +} + +func TestBaseViewValidate(t *testing.T) { + ctx := context.Background() + if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil { + t.Fatalf("create validate err=%v", err) + } + if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err != nil { + t.Fatalf("invalid view json should bypass CLI validate, err=%v", err) + } +} diff --git a/shortcuts/base/dashboard_block_create.go b/shortcuts/base/dashboard_block_create.go new file mode 100644 index 00000000..7d16d06b --- /dev/null +++ b/shortcuts/base/dashboard_block_create.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockCreate = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-create", + Description: "Create a block in a dashboard", + Risk: "write", + Scopes: []string{"base:dashboard:create"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + {Name: "name", Desc: "block name", Required: true}, + {Name: "type", Desc: "block type: column / bar / line / pie / ring / area / combo / scatter / funnel / wordCloud / radar / statistics", Required: true}, + {Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"}, + {Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, + {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Bool("no-validate") { + return nil + } + raw := runtime.Str("data-config") + if strings.TrimSpace(raw) == "" { + return nil // 允许无 data_config 的创建(某些类型可先创建后配置) + } + cfg, err := parseJSONObject(raw, "data-config") + if err != nil { + return err + } + norm := normalizeDataConfig(cfg) + if errs := validateBlockDataConfig(runtime.Str("type"), norm); len(errs) > 0 { + return formatDataConfigErrors(errs) + } + // 用规范化后的 JSON 覆写 flag,确保后续透传一致 + b, _ := json.Marshal(norm) + _ = runtime.Cmd.Flags().Set("data-config", string(b)) + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := runtime.Str("name"); name != "" { + body["name"] = name + } + if t := runtime.Str("type"); t != "" { + body["type"] = t + } + if raw := runtime.Str("data-config"); raw != "" { + if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + body["data_config"] = parsed + } + } + params := map[string]interface{}{} + if uid := runtime.Str("user-id-type"); uid != "" { + params["user_id_type"] = uid + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). + Params(params). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockCreate(runtime) + }, +} diff --git a/shortcuts/base/dashboard_block_delete.go b/shortcuts/base/dashboard_block_delete.go new file mode 100644 index 00000000..97d667a9 --- /dev/null +++ b/shortcuts/base/dashboard_block_delete.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockDelete = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-delete", + Description: "Delete a dashboard block", + Risk: "high-risk-write", + Scopes: []string{"base:dashboard:delete"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + blockIDFlag(true), + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")). + Set("block_id", runtime.Str("block-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockDelete(runtime) + }, +} diff --git a/shortcuts/base/dashboard_block_get.go b/shortcuts/base/dashboard_block_get.go new file mode 100644 index 00000000..b6c605e3 --- /dev/null +++ b/shortcuts/base/dashboard_block_get.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockGet = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-get", + Description: "Get a dashboard block by ID", + Risk: "read", + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + blockIDFlag(true), + {Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" { + params["user_id_type"] = uid + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). + Params(params). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")). + Set("block_id", runtime.Str("block-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockGet(runtime) + }, +} diff --git a/shortcuts/base/dashboard_block_list.go b/shortcuts/base/dashboard_block_list.go new file mode 100644 index 00000000..852dcc18 --- /dev/null +++ b/shortcuts/base/dashboard_block_list.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockList = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-list", + Description: "List blocks in a dashboard", + Risk: "read", + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + {Name: "page-size", Desc: "page size (max 100)"}, + {Name: "page-token", Desc: "pagination token"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" { + params["page_size"] = ps + } + if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" { + params["page_token"] = pt + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). + Params(params). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockList(runtime) + }, +} diff --git a/shortcuts/base/dashboard_block_update.go b/shortcuts/base/dashboard_block_update.go new file mode 100644 index 00000000..a194b03a --- /dev/null +++ b/shortcuts/base/dashboard_block_update.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockUpdate = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-update", + Description: "Update a dashboard block", + Risk: "write", + Scopes: []string{"base:dashboard:update"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + blockIDFlag(true), + {Name: "name", Desc: "new block name"}, + {Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"}, + {Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, + {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Bool("no-validate") { + return nil + } + raw := runtime.Str("data-config") + if strings.TrimSpace(raw) == "" { + return nil + } + cfg, err := parseJSONObject(raw, "data-config") + if err != nil { + return err + } + norm := normalizeDataConfig(cfg) + if errs := validateBlockDataConfig("", norm); len(errs) > 0 { // update 时不强校验类型特性 + return formatDataConfigErrors(errs) + } + b, _ := json.Marshal(norm) + _ = runtime.Cmd.Flags().Set("data-config", string(b)) + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := runtime.Str("name"); name != "" { + body["name"] = name + } + if raw := runtime.Str("data-config"); raw != "" { + if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + body["data_config"] = parsed + } + } + params := map[string]interface{}{} + if uid := runtime.Str("user-id-type"); uid != "" { + params["user_id_type"] = uid + } + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). + Params(params). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")). + Set("block_id", runtime.Str("block-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockUpdate(runtime) + }, +} diff --git a/shortcuts/base/dashboard_create.go b/shortcuts/base/dashboard_create.go new file mode 100644 index 00000000..214d8d17 --- /dev/null +++ b/shortcuts/base/dashboard_create.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardCreate = common.Shortcut{ + Service: "base", + Command: "+dashboard-create", + Description: "Create a dashboard in a base", + Risk: "write", + Scopes: []string{"base:dashboard:create"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "name", Desc: "dashboard name", Required: true}, + {Name: "theme-style", Desc: "theme style"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := runtime.Str("name"); name != "" { + body["name"] = name + } + if themeStyle := runtime.Str("theme-style"); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/dashboards"). + Body(body). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardCreate(runtime) + }, +} diff --git a/shortcuts/base/dashboard_delete.go b/shortcuts/base/dashboard_delete.go new file mode 100644 index 00000000..5d6df006 --- /dev/null +++ b/shortcuts/base/dashboard_delete.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardDelete = common.Shortcut{ + Service: "base", + Command: "+dashboard-delete", + Description: "Delete a dashboard", + Risk: "high-risk-write", + Scopes: []string{"base:dashboard:delete"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardDelete(runtime) + }, +} diff --git a/shortcuts/base/dashboard_get.go b/shortcuts/base/dashboard_get.go new file mode 100644 index 00000000..90f21f6c --- /dev/null +++ b/shortcuts/base/dashboard_get.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardGet = common.Shortcut{ + Service: "base", + Command: "+dashboard-get", + Description: "Get a dashboard by ID", + Risk: "read", + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardGet(runtime) + }, +} diff --git a/shortcuts/base/dashboard_list.go b/shortcuts/base/dashboard_list.go new file mode 100644 index 00000000..8d270daa --- /dev/null +++ b/shortcuts/base/dashboard_list.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardList = common.Shortcut{ + Service: "base", + Command: "+dashboard-list", + Description: "List dashboards in a base", + Risk: "read", + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "page-size", Desc: "page size (max 100)"}, + {Name: "page-token", Desc: "pagination token"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" { + params["page_size"] = ps + } + if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" { + params["page_token"] = pt + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/dashboards"). + Params(params). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardList(runtime) + }, +} diff --git a/shortcuts/base/dashboard_ops.go b/shortcuts/base/dashboard_ops.go new file mode 100644 index 00000000..c319fde5 --- /dev/null +++ b/shortcuts/base/dashboard_ops.go @@ -0,0 +1,303 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dashboardIDFlag(required bool) common.Flag { + return common.Flag{Name: "dashboard-id", Desc: "dashboard ID", Required: required} +} + +func blockIDFlag(required bool) common.Flag { + return common.Flag{Name: "block-id", Desc: "dashboard block ID", Required: required} +} + +func dryRunDashboardBase(runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")). + Set("block_id", runtime.Str("block-id")) +} + +func dryRunDashboardList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" { + params["page_size"] = pageSize + } + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + return dryRunDashboardBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/dashboards"). + Params(params) +} + +func dryRunDashboardGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunDashboardBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id") +} + +func dryRunDashboardCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{"name": runtime.Str("name")} + if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + return dryRunDashboardBase(runtime). + POST("/open-apis/base/v3/bases/:base_token/dashboards"). + Body(body) +} + +func dryRunDashboardUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + return dryRunDashboardBase(runtime). + PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). + Body(body) +} + +func dryRunDashboardDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunDashboardBase(runtime). + DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id") +} + +func dryRunDashboardBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" { + params["page_size"] = pageSize + } + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + return dryRunDashboardBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). + Params(params) +} + +func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + return dryRunDashboardBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). + Params(params) +} + +func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if blockType := strings.TrimSpace(runtime.Str("type")); blockType != "" { + body["type"] = blockType + } + if raw := runtime.Str("data-config"); raw != "" { + if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + body["data_config"] = parsed + } + } + + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + return dryRunDashboardBase(runtime). + POST("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). + Params(params). + Body(body) +} + +func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if raw := runtime.Str("data-config"); raw != "" { + if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + body["data_config"] = parsed + } + } + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + return dryRunDashboardBase(runtime). + PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). + Params(params). + Body(body) +} + +func dryRunDashboardBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunDashboardBase(runtime). + DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id") +} + +// ── Dashboard CRUD ────────────────────────────────────────────────── + +func executeDashboardList(runtime *common.RuntimeContext) error { + params := map[string]interface{}{} + if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" { + params["page_size"] = pageSize + } + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards"), params, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil +} + +func executeDashboardGet(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"dashboard": data}, nil) + return nil +} + +func executeDashboardCreate(runtime *common.RuntimeContext) error { + body := map[string]interface{}{"name": runtime.Str("name")} + if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "dashboards"), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"dashboard": data, "created": true}, nil) + return nil +} + +func executeDashboardUpdate(runtime *common.RuntimeContext) error { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"dashboard": data, "updated": true}, nil) + return nil +} + +func executeDashboardDelete(runtime *common.RuntimeContext) error { + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "dashboard_id": runtime.Str("dashboard-id")}, nil) + return nil +} + +// ── Dashboard Block CRUD ──────────────────────────────────────────── + +func executeDashboardBlockList(runtime *common.RuntimeContext) error { + params := map[string]interface{}{} + if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" { + params["page_size"] = pageSize + } + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks"), params, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil +} + +func executeDashboardBlockGet(runtime *common.RuntimeContext) error { + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), params, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"block": data}, nil) + return nil +} + +func executeDashboardBlockCreate(runtime *common.RuntimeContext) error { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if blockType := strings.TrimSpace(runtime.Str("type")); blockType != "" { + body["type"] = blockType + } + if raw := runtime.Str("data-config"); raw != "" { + parsed, err := parseJSONObject(raw, "data-config") + if err != nil { + return err + } + body["data_config"] = parsed + } + + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks"), params, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"block": data, "created": true}, nil) + return nil +} + +func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if raw := runtime.Str("data-config"); raw != "" { + parsed, err := parseJSONObject(raw, "data-config") + if err != nil { + return err + } + body["data_config"] = parsed + } + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), params, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"block": data, "updated": true}, nil) + return nil +} + +func executeDashboardBlockDelete(runtime *common.RuntimeContext) error { + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "block_id": runtime.Str("block-id")}, nil) + return nil +} diff --git a/shortcuts/base/dashboard_update.go b/shortcuts/base/dashboard_update.go new file mode 100644 index 00000000..5832e902 --- /dev/null +++ b/shortcuts/base/dashboard_update.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardUpdate = common.Shortcut{ + Service: "base", + Command: "+dashboard-update", + Description: "Update a dashboard", + Risk: "write", + Scopes: []string{"base:dashboard:update"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + {Name: "name", Desc: "new dashboard name"}, + {Name: "theme-style", Desc: "theme style"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := runtime.Str("name"); name != "" { + body["name"] = name + } + if themeStyle := runtime.Str("theme-style"); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardUpdate(runtime) + }, +} diff --git a/shortcuts/base/field_create.go b/shortcuts/base/field_create.go new file mode 100644 index 00000000..715b4d7d --- /dev/null +++ b/shortcuts/base/field_create.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldCreate = common.Shortcut{ + Service: "base", + Command: "+field-create", + Description: "Create a field", + Risk: "write", + Scopes: []string{"base:field:create"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "json", Desc: "field property JSON object", Required: true}, + {Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateFieldCreate(runtime) + }, + DryRun: dryRunFieldCreate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldCreate(runtime) + }, +} diff --git a/shortcuts/base/field_delete.go b/shortcuts/base/field_delete.go new file mode 100644 index 00000000..d242763d --- /dev/null +++ b/shortcuts/base/field_delete.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldDelete = common.Shortcut{ + Service: "base", + Command: "+field-delete", + Description: "Delete a field by ID or name", + Risk: "high-risk-write", + Scopes: []string{"base:field:delete"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, + DryRun: dryRunFieldDelete, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldDelete(runtime) + }, +} diff --git a/shortcuts/base/field_get.go b/shortcuts/base/field_get.go new file mode 100644 index 00000000..63d66a69 --- /dev/null +++ b/shortcuts/base/field_get.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldGet = common.Shortcut{ + Service: "base", + Command: "+field-get", + Description: "Get a field by ID or name", + Risk: "read", + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, + DryRun: dryRunFieldGet, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldGet(runtime) + }, +} diff --git a/shortcuts/base/field_list.go b/shortcuts/base/field_list.go new file mode 100644 index 00000000..da487827 --- /dev/null +++ b/shortcuts/base/field_list.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldList = common.Shortcut{ + Service: "base", + Command: "+field-list", + Description: "List fields in a table", + Risk: "read", + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "100", Desc: "pagination size"}, + }, + DryRun: dryRunFieldList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldList(runtime) + }, +} diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go new file mode 100644 index 00000000..0976c90d --- /dev/null +++ b/shortcuts/base/field_ops.go @@ -0,0 +1,222 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). + Params(map[string]interface{}{"offset": offset, "limit": limit}). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)) +} + +func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("field_id", runtime.Str("field-id")) +} + +func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, _ := parseJSONObject(runtime.Str("json"), "json") + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)) +} + +func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, _ := parseJSONObject(runtime.Str("json"), "json") + return common.NewDryRunAPI(). + PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("field_id", runtime.Str("field-id")) +} + +func dryRunFieldDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("field_id", runtime.Str("field-id")) +} + +func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{ + "offset": runtime.Int("offset"), + "limit": runtime.Int("limit"), + } + if params["limit"].(int) <= 0 { + params["limit"] = 30 + } + if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + params["query"] = keyword + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id/options"). + Params(params). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("field_id", runtime.Str("field-id")) +} + +func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { + raw, _ := loadJSONInput(runtime.Str("json"), "json") + if raw == "" { + return nil, nil + } + var body map[string]interface{} + _ = common.ParseJSON([]byte(raw), &body) + if body == nil { + return nil, nil + } + return body, nil +} + +func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error { + fieldType := strings.ToLower(strings.TrimSpace(common.GetString(body, "type"))) + if (fieldType == "formula" || fieldType == "lookup") && !runtime.Bool("i-have-read-guide") { + guidePath := "skills/lark-base/references/formula-field-guide.md" + if fieldType == "lookup" { + guidePath = "skills/lark-base/references/lookup-field-guide.md" + } + return common.FlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath) + } + return nil +} + +func validateFieldCreate(runtime *common.RuntimeContext) error { + body, err := validateFieldJSON(runtime) + if err != nil { + return err + } + return validateFormulaLookupGuideAck(runtime, "+field-create", body) +} + +func validateFieldUpdate(runtime *common.RuntimeContext) error { + body, err := validateFieldJSON(runtime) + if err != nil { + return err + } + return validateFormulaLookupGuideAck(runtime, "+field-update", body) +} + +func executeFieldList(runtime *common.RuntimeContext) error { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit) + if err != nil { + return err + } + if total == 0 { + total = len(fields) + } + runtime.Out(map[string]interface{}{"items": simplifyFields(fields), "offset": offset, "limit": limit, "count": len(fields), "total": total}, nil) + return nil +} + +func executeFieldGet(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + fieldRef := runtime.Str("field-id") + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"field": data}, nil) + return nil +} + +func executeFieldCreate(runtime *common.RuntimeContext) error { + body, err := parseJSONObject(runtime.Str("json"), "json") + if err != nil { + return err + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"field": data, "created": true}, nil) + return nil +} + +func executeFieldUpdate(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + body, err := parseJSONObject(runtime.Str("json"), "json") + if err != nil { + return err + } + fieldRef := runtime.Str("field-id") + data, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil) + return nil +} + +func executeFieldDelete(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + fieldRef := runtime.Str("field-id") + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "field_id": fieldRef, "field_name": fieldRef}, nil) + return nil +} + +func executeFieldSearchOptions(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + fieldRef := runtime.Str("field-id") + params := map[string]interface{}{ + "offset": runtime.Int("offset"), + "limit": runtime.Int("limit"), + } + if params["limit"].(int) <= 0 { + params["limit"] = 30 + } + if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + params["query"] = keyword + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef, "options"), params, nil) + if err != nil { + return err + } + options, _ := data["options"].([]interface{}) + total := toInt(data["total"]) + if total == 0 { + total = len(options) + } + runtime.Out(map[string]interface{}{ + "field_id": fieldRef, + "field_name": fieldRef, + "keyword": strings.TrimSpace(runtime.Str("keyword")), + "options": options, + "total": total, + }, nil) + return nil +} diff --git a/shortcuts/base/field_search_options.go b/shortcuts/base/field_search_options.go new file mode 100644 index 00000000..674cfe6b --- /dev/null +++ b/shortcuts/base/field_search_options.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldSearchOptions = common.Shortcut{ + Service: "base", + Command: "+field-search-options", + Description: "Search select options of a field", + Risk: "read", + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + fieldRefFlag(true), + {Name: "keyword", Desc: "keyword for option query"}, + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "30", Desc: "pagination size"}, + }, + DryRun: dryRunFieldSearchOptions, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldSearchOptions(runtime) + }, +} diff --git a/shortcuts/base/field_update.go b/shortcuts/base/field_update.go new file mode 100644 index 00000000..fd8d755d --- /dev/null +++ b/shortcuts/base/field_update.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldUpdate = common.Shortcut{ + Service: "base", + Command: "+field-update", + Description: "Update a field by ID or name", + Risk: "write", + Scopes: []string{"base:field:update"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + fieldRefFlag(true), + {Name: "json", Desc: "field property JSON object", Required: true}, + {Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateFieldUpdate(runtime) + }, + DryRun: dryRunFieldUpdate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldUpdate(runtime) + }, +} diff --git a/shortcuts/base/helpers.go b/shortcuts/base/helpers.go new file mode 100644 index 00000000..9032ab5a --- /dev/null +++ b/shortcuts/base/helpers.go @@ -0,0 +1,1129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + batchSize = 500 + baseV3ServicePath = "/open-apis/base/v3" +) + +type fieldTypeSpec struct { + Type string + Extra map[string]interface{} +} + +func parseJSONObject(raw string, flagName string) (map[string]interface{}, error) { + resolved, err := loadJSONInput(raw, flagName) + if err != nil { + return nil, err + } + var result map[string]interface{} + if err := common.ParseJSON([]byte(resolved), &result); err != nil { + return nil, formatJSONError(flagName, "object", err) + } + return result, nil +} + +func parseJSONArray(raw string, flagName string) ([]interface{}, error) { + resolved, err := loadJSONInput(raw, flagName) + if err != nil { + return nil, err + } + var result []interface{} + if err := common.ParseJSON([]byte(resolved), &result); err != nil { + return nil, formatJSONError(flagName, "array", err) + } + return result, nil +} + +func parseStringListFlexible(raw string, flagName string) ([]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + resolved, err := loadJSONInput(raw, flagName) + if err != nil { + return nil, err + } + if strings.HasPrefix(resolved, "[") { + var result []string + if err := common.ParseJSON([]byte(resolved), &result); err != nil { + return nil, formatJSONError(flagName, "string array", err) + } + return result, nil + } + raw = resolved + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + item := strings.TrimSpace(part) + if item != "" { + result = append(result, item) + } + } + return result, nil +} + +func parseStringList(raw string) []string { + items, _ := parseStringListFlexible(raw, "fields") + return items +} + +func deepMergeMaps(dst, src map[string]interface{}) map[string]interface{} { + if dst == nil { + dst = map[string]interface{}{} + } + for key, value := range src { + if srcMap, ok := value.(map[string]interface{}); ok { + if dstMap, ok := dst[key].(map[string]interface{}); ok { + dst[key] = deepMergeMaps(dstMap, srcMap) + } else { + dst[key] = deepMergeMaps(map[string]interface{}{}, srcMap) + } + continue + } + dst[key] = value + } + return dst +} + +func cloneMap(src map[string]interface{}) map[string]interface{} { + if src == nil { + return nil + } + dst := make(map[string]interface{}, len(src)) + for key, value := range src { + dst[key] = cloneValue(value) + } + return dst +} + +func cloneValue(value interface{}) interface{} { + switch val := value.(type) { + case map[string]interface{}: + return cloneMap(val) + case []interface{}: + cloned := make([]interface{}, len(val)) + for i, item := range val { + cloned[i] = cloneValue(item) + } + return cloned + default: + return val + } +} + +func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) { + trimmed := strings.TrimSpace(typeName) + if trimmed == "" { + return fieldTypeSpec{}, fmt.Errorf("field type cannot be empty") + } + switch strings.ToLower(trimmed) { + case "text", "phone", "url", "email", "barcode": + return fieldTypeSpec{Type: "text"}, nil + case "number": + return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "number", "formatter": "0"}}}, nil + case "currency": + return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "currency", "currency_code": "CNY", "formatter": "0.00"}}}, nil + case "progress": + return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "progress", "min": 0, "max": 100, "color": "Blue"}}}, nil + case "rating": + return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "rating", "icon": "star", "min": 1, "max": 5}}}, nil + case "singleselect", "single_select", "single-select": + return fieldTypeSpec{Type: "select", Extra: map[string]interface{}{"multiple": false}}, nil + case "multiselect", "multi_select", "multi-select": + return fieldTypeSpec{Type: "select", Extra: map[string]interface{}{"multiple": true}}, nil + case "datetime", "date", "date_time", "date-time": + return fieldTypeSpec{Type: "datetime", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil + case "checkbox": + return fieldTypeSpec{Type: "checkbox"}, nil + case "user", "groupchat", "group_chat", "group-chat": + return fieldTypeSpec{Type: "user", Extra: map[string]interface{}{"multiple": true}}, nil + case "attachment": + return fieldTypeSpec{Type: "attachment"}, nil + case "link": + return fieldTypeSpec{Type: "link"}, nil + case "twowaylink", "two_way_link", "two-way-link": + return fieldTypeSpec{Type: "link", Extra: map[string]interface{}{"bidirectional": true}}, nil + case "formula": + return fieldTypeSpec{Type: "formula"}, nil + case "location": + return fieldTypeSpec{Type: "location"}, nil + case "autonumber", "auto_number", "auto-number": + return fieldTypeSpec{Type: "auto_number", Extra: map[string]interface{}{"style": map[string]interface{}{"rules": []interface{}{map[string]interface{}{"type": "text", "text": "NO."}, map[string]interface{}{"type": "incremental_number", "length": 3}}}}}, nil + case "createdtime", "created_time", "created-time": + return fieldTypeSpec{Type: "created_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil + case "modifiedtime", "modified_time", "modified-time": + return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil + default: + return fieldTypeSpec{}, fmt.Errorf("unsupported field type %q in base/v3", typeName) + } +} + +func normalizeFieldTypeName(typeName string) string { + return strings.TrimSpace(typeName) +} + +func normalizeViewTypeName(typeName string) string { + trimmed := strings.TrimSpace(typeName) + if trimmed == "" { + return trimmed + } + switch strings.ToLower(trimmed) { + case "grid": + return "grid" + case "kanban": + return "kanban" + case "gallery": + return "gallery" + case "gantt": + return "gantt" + case "calendar": + return "calendar" + default: + return trimmed + } +} + +func normalizeSelectOptions(raw interface{}) []interface{} { + src, ok := raw.([]interface{}) + if !ok { + return nil + } + result := make([]interface{}, 0, len(src)) + for _, item := range src { + switch v := item.(type) { + case string: + result = append(result, map[string]interface{}{"name": v}) + case map[string]interface{}: + option := map[string]interface{}{} + if name, _ := v["name"].(string); name != "" { + option["name"] = name + } + if hue, _ := v["hue"].(string); hue != "" { + option["hue"] = hue + } + if lightness, _ := v["lightness"].(string); lightness != "" { + option["lightness"] = lightness + } + if len(option) > 0 { + result = append(result, option) + } + } + } + return result +} + +func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) { + if isPrimary { + return nil, fmt.Errorf("base/v3 does not support setting primary field in field body") + } + if isHidden { + return nil, fmt.Errorf("base/v3 does not support hidden field creation in field body") + } + spec, err := resolveFieldTypeSpec(typeName) + if err != nil { + return nil, err + } + body := map[string]interface{}{ + "type": spec.Type, + "name": fieldName, + } + body = deepMergeMaps(body, cloneMap(spec.Extra)) + if description != "" { + _ = description + } + if uiType != "" { + switch strings.ToLower(uiType) { + case "currency": + body["type"] = "number" + body["style"] = map[string]interface{}{"type": "currency", "currency_code": "CNY", "formatter": "0.00"} + case "progress": + body["type"] = "number" + body["style"] = map[string]interface{}{"type": "progress", "min": 0, "max": 100, "color": "Blue"} + case "rating": + body["type"] = "number" + body["style"] = map[string]interface{}{"type": "rating", "icon": "star", "min": 1, "max": 5} + } + } + if property == nil { + return body, nil + } + property = cloneMap(property) + switch body["type"] { + case "number", "datetime", "created_at", "updated_at", "auto_number": + style, _ := body["style"].(map[string]interface{}) + if style == nil { + style = map[string]interface{}{} + } + if inner, ok := property["style"].(map[string]interface{}); ok { + style = deepMergeMaps(style, inner) + delete(property, "style") + } + style = deepMergeMaps(style, property) + if len(style) > 0 { + body["style"] = style + } + case "select": + if options, ok := property["options"]; ok { + body["options"] = normalizeSelectOptions(options) + delete(property, "options") + } + if multiple, ok := property["multiple"].(bool); ok { + body["multiple"] = multiple + delete(property, "multiple") + } + body = deepMergeMaps(body, property) + case "user": + if multiple, ok := property["multiple"].(bool); ok { + body["multiple"] = multiple + delete(property, "multiple") + } + case "link": + if tableID, _ := property["table_id"].(string); tableID != "" { + body["link_table"] = tableID + delete(property, "table_id") + } + if tableID, _ := property["link_table"].(string); tableID != "" { + body["link_table"] = tableID + delete(property, "link_table") + } + if multiple, ok := property["multiple"].(bool); ok { + _ = multiple + delete(property, "multiple") + } + if backName, _ := property["back_field_name"].(string); backName != "" { + body["bidirectional"] = true + body["bidirectional_link_field_name"] = backName + delete(property, "back_field_name") + } + body = deepMergeMaps(body, property) + case "formula": + if expr, _ := property["formula_expression"].(string); expr != "" { + body["expression"] = expr + delete(property, "formula_expression") + } + if expr, _ := property["expression"].(string); expr != "" { + body["expression"] = expr + delete(property, "expression") + } + body = deepMergeMaps(body, property) + default: + body = deepMergeMaps(body, property) + } + return body, nil +} + +func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{}, error) { + if rawFields != "" { + var fields []interface{} + if err := common.ParseJSON([]byte(rawFields), &fields); err != nil { + return nil, fmt.Errorf("--fields invalid JSON, must be a field definition array") + } + return fields, nil + } + specs, err := parseNamedTypeSpecs(rawFieldSpecs, "field-specs") + if err != nil { + return nil, err + } + fields := make([]interface{}, 0, len(specs)) + for _, spec := range specs { + body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false) + if err != nil { + return nil, fmt.Errorf("field %q: %w", spec.Name, err) + } + fields = append(fields, body) + } + return fields, nil +} + +func baseV3Path(parts ...string) string { + clean := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.Trim(part, "/") + if part != "" { + clean = append(clean, url.PathEscape(part)) + } + } + return baseV3ServicePath + "/" + strings.Join(clean, "/") +} + +func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + queryParams := make(larkcore.QueryParams) + for k, v := range params { + queryParams.Set(k, fmt.Sprintf("%v", v)) + } + req := &larkcore.ApiReq{ + HttpMethod: strings.ToUpper(method), + ApiPath: path, + Body: data, + QueryParams: queryParams, + } + h := make(http.Header) + h.Set("X-App-Id", runtime.Config.AppID) + resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h)) + if err != nil { + return nil, err + } + if resp.StatusCode >= http.StatusBadRequest { + body := strings.TrimSpace(string(resp.RawBody)) + if body == "" { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) + } + var result map[string]interface{} + dec := json.NewDecoder(bytes.NewReader(resp.RawBody)) + dec.UseNumber() + if err := dec.Decode(&result); err != nil { + return nil, fmt.Errorf("response parse error: %w", err) + } + return result, nil +} + +func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + result, err := baseV3Raw(runtime, method, path, params, data) + return handleBaseAPIResult(result, err, "API call failed") +} + +func baseV3CallAny(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (interface{}, error) { + result, err := baseV3Raw(runtime, method, path, params, data) + return handleBaseAPIResultAny(result, err, "API call failed") +} + +func toInt(v interface{}) int { + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + case json.Number: + i, _ := n.Int64() + return int(i) + case string: + i, _ := strconv.Atoi(strings.TrimSpace(n)) + return i + default: + return 0 + } +} + +func toStringSlice(v interface{}) []string { + arr, ok := v.([]interface{}) + if !ok { + return nil + } + result := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result +} + +func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) { + if limit <= 0 { + return nil, 0, fmt.Errorf("limit must be greater than 0") + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil) + if err != nil { + return nil, 0, err + } + rawItems, _ := data["tables"].([]interface{}) + if len(rawItems) == 0 { + rawItems, _ = data["items"].([]interface{}) + } + if len(rawItems) == 0 { + if _, hasID := data["id"]; hasID { + rawItems = []interface{}{data} + } + } + items := make([]map[string]interface{}, 0, len(rawItems)) + for _, item := range rawItems { + if m, ok := item.(map[string]interface{}); ok { + items = append(items, m) + } + } + total := toInt(data["total"]) + if total == 0 { + total = len(items) + } + return items, total, nil +} + +func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) { + if limit <= 0 { + return nil, 0, fmt.Errorf("limit must be greater than 0") + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil) + if err != nil { + return nil, 0, err + } + rawItems, _ := data["fields"].([]interface{}) + items := make([]map[string]interface{}, 0, len(rawItems)) + for _, item := range rawItems { + if m, ok := item.(map[string]interface{}); ok { + items = append(items, m) + } + } + total := toInt(data["total"]) + if total == 0 { + total = len(items) + } + return items, total, nil +} + +func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) { + if limit <= 0 { + return nil, 0, fmt.Errorf("limit must be greater than 0") + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil) + if err != nil { + return nil, 0, err + } + rawItems, _ := data["views"].([]interface{}) + items := make([]map[string]interface{}, 0, len(rawItems)) + for _, item := range rawItems { + if m, ok := item.(map[string]interface{}); ok { + items = append(items, m) + } + } + total := toInt(data["total"]) + if total == 0 { + total = len(items) + } + return items, total, nil +} + +func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]interface{}, error) { + for _, field := range fields { + if ref == fieldID(field) || ref == fieldName(field) { + return field, nil + } + } + return nil, fmt.Errorf("field %q not found", ref) +} + +func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) { + for _, table := range tables { + if ref == tableID(table) || ref == tableNameFromMap(table) { + return table, nil + } + } + return nil, fmt.Errorf("table %q not found", ref) +} + +func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) { + for _, view := range views { + if ref == viewID(view) || ref == viewName(view) { + return view, nil + } + } + return nil, fmt.Errorf("view %q not found", ref) +} + +func normalizeRecordInputs(raw string) ([]map[string]interface{}, error) { + var records []interface{} + if err := common.ParseJSON([]byte(raw), &records); err != nil { + return nil, fmt.Errorf("--records invalid JSON, must be a record array") + } + result := make([]map[string]interface{}, 0, len(records)) + for idx, item := range records { + record, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("record %d must be an object", idx+1) + } + if fields, ok := record["fields"].(map[string]interface{}); ok { + normalized := map[string]interface{}{"fields": fields} + if recordID, ok := record["record_id"].(string); ok && recordID != "" { + normalized["record_id"] = recordID + } + result = append(result, normalized) + continue + } + result = append(result, map[string]interface{}{"fields": record}) + } + return result, nil +} + +func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} { + if size <= 0 { + size = 1 + } + chunks := [][]map[string]interface{}{} + for start := 0; start < len(records); start += size { + end := start + size + if end > len(records) { + end = len(records) + } + chunks = append(chunks, records[start:end]) + } + return chunks +} + +func chunkStringIDs(ids []string, size int) [][]string { + if size <= 0 { + size = 1 + } + chunks := [][]string{} + for start := 0; start < len(ids); start += size { + end := start + size + if end > len(ids) { + end = len(ids) + } + chunks = append(chunks, ids[start:end]) + } + return chunks +} + +func fieldName(field map[string]interface{}) string { + if v, _ := field["name"].(string); v != "" { + return v + } + v, _ := field["field_name"].(string) + return v +} + +func fieldID(field map[string]interface{}) string { + if v, _ := field["id"].(string); v != "" { + return v + } + v, _ := field["field_id"].(string) + return v +} + +func fieldTypeName(field map[string]interface{}) string { + if v, _ := field["type"].(string); v != "" { + return v + } + return fmt.Sprintf("%v", field["type"]) +} + +func tableID(table map[string]interface{}) string { + if v, _ := table["id"].(string); v != "" { + return v + } + v, _ := table["table_id"].(string) + return v +} + +func tableNameFromMap(table map[string]interface{}) string { + if v, _ := table["name"].(string); v != "" { + return v + } + v, _ := table["table_name"].(string) + return v +} + +func viewID(view map[string]interface{}) string { + if v, _ := view["id"].(string); v != "" { + return v + } + v, _ := view["view_id"].(string) + return v +} + +func viewName(view map[string]interface{}) string { + if v, _ := view["name"].(string); v != "" { + return v + } + v, _ := view["view_name"].(string) + return v +} + +func viewType(view map[string]interface{}) string { + if v, _ := view["type"].(string); v != "" { + return v + } + v, _ := view["view_type"].(string) + return v +} + +func simplifyFields(fields []map[string]interface{}) []interface{} { + items := make([]interface{}, 0, len(fields)) + for _, field := range fields { + entry := map[string]interface{}{ + "field_id": fieldID(field), + "field_name": fieldName(field), + "type": fieldTypeName(field), + } + if style, ok := field["style"].(map[string]interface{}); ok && len(style) > 0 { + entry["style"] = style + } + if multiple, ok := field["multiple"].(bool); ok { + entry["multiple"] = multiple + } + items = append(items, entry) + } + return items +} + +func simplifyViews(views []map[string]interface{}) []interface{} { + items := make([]interface{}, 0, len(views)) + for _, view := range views { + items = append(items, map[string]interface{}{ + "view_id": viewID(view), + "view_name": viewName(view), + "view_type": viewType(view), + }) + } + return items +} + +func canonicalValue(v interface{}) string { + switch val := v.(type) { + case nil: + return "" + case []interface{}: + if len(val) == 1 { + return canonicalValue(val[0]) + } + case map[string]interface{}: + if id, ok := val["id"]; ok { + return canonicalValue(id) + } + if text, ok := val["text"]; ok { + return canonicalValue(text) + } + case string: + return strings.TrimSpace(val) + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + } + b, _ := json.Marshal(v) + return string(b) +} + +func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) { + var tuples []interface{} + if err := common.ParseJSON([]byte(raw), &tuples); err != nil { + return nil, fmt.Errorf("--%s invalid JSON array", flagName) + } + result := make([]namedTypeSpec, 0, len(tuples)) + for idx, item := range tuples { + pair, ok := item.([]interface{}) + if !ok || len(pair) != 2 { + return nil, fmt.Errorf("--%s item %d must be [name, type]", flagName, idx+1) + } + name, ok1 := pair[0].(string) + typeName, ok2 := pair[1].(string) + if !ok1 || !ok2 { + return nil, fmt.Errorf("--%s item %d must be [string, string]", flagName, idx+1) + } + result = append(result, namedTypeSpec{Name: name, Type: typeName}) + } + return result, nil +} + +type namedTypeSpec struct { + Name string + Type string +} + +func selectRecordFields(items []map[string]interface{}, fields []string) []map[string]interface{} { + if len(fields) == 0 { + return items + } + result := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + entry := map[string]interface{}{} + if recordID, _ := item["record_id"].(string); recordID != "" { + entry["record_id"] = recordID + } + selected := map[string]interface{}{} + fieldMap, _ := item["fields"].(map[string]interface{}) + for _, name := range fields { + if value, ok := fieldMap[name]; ok { + selected[name] = value + } + } + entry["fields"] = selected + result = append(result, entry) + } + return result +} + +func compareScalar(left interface{}, right interface{}) int { + lf, lerr := strconv.ParseFloat(canonicalValue(left), 64) + rf, rerr := strconv.ParseFloat(canonicalValue(right), 64) + if lerr == nil && rerr == nil { + switch { + case lf < rf: + return -1 + case lf > rf: + return 1 + default: + return 0 + } + } + ls := canonicalValue(left) + rs := canonicalValue(right) + switch { + case ls < rs: + return -1 + case ls > rs: + return 1 + default: + return 0 + } +} + +func asSet(v interface{}) map[string]bool { + set := map[string]bool{} + switch val := v.(type) { + case []interface{}: + for _, item := range val { + set[canonicalValue(item)] = true + } + default: + if c := canonicalValue(v); c != "" { + set[c] = true + } + } + return set +} + +func valueEmpty(v interface{}) bool { + switch val := v.(type) { + case nil: + return true + case string: + return strings.TrimSpace(val) == "" + case []interface{}: + return len(val) == 0 + case map[string]interface{}: + return len(val) == 0 + default: + return canonicalValue(v) == "" + } +} + +func matchesCondition(value interface{}, condition []interface{}) bool { + if len(condition) < 2 { + return false + } + op, _ := condition[1].(string) + var target interface{} + if len(condition) > 2 { + target = condition[2] + } + switch op { + case "==": + return compareScalar(value, target) == 0 + case "!=": + return compareScalar(value, target) != 0 + case ">": + return compareScalar(value, target) > 0 + case ">=": + return compareScalar(value, target) >= 0 + case "<": + return compareScalar(value, target) < 0 + case "<=": + return compareScalar(value, target) <= 0 + case "empty": + return valueEmpty(value) + case "non_empty": + return !valueEmpty(value) + case "intersects": + left := asSet(value) + right := asSet(target) + for key := range left { + if right[key] { + return true + } + } + return false + case "disjoint": + left := asSet(value) + right := asSet(target) + for key := range left { + if right[key] { + return false + } + } + return true + default: + return false + } +} + +func normalizeFilterConfig(raw map[string]interface{}) (string, [][]interface{}) { + logic, _ := raw["logic"].(string) + if logic == "" { + logic, _ = raw["conjunction"].(string) + } + if logic == "" { + logic = "and" + } + rawConditions, _ := raw["conditions"].([]interface{}) + conditions := make([][]interface{}, 0, len(rawConditions)) + for _, item := range rawConditions { + switch cond := item.(type) { + case []interface{}: + conditions = append(conditions, cond) + case map[string]interface{}: + fieldName, ok := cond["field"] + if !ok { + fieldName = cond["field_name"] + } + conditions = append(conditions, []interface{}{fieldName, cond["operator"], cond["value"]}) + } + } + return logic, conditions +} + +func filterRecords(items []map[string]interface{}, filter map[string]interface{}) []map[string]interface{} { + logic, conditions := normalizeFilterConfig(filter) + if len(conditions) == 0 { + return items + } + result := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + fields, _ := item["fields"].(map[string]interface{}) + matches := logic != "or" + for _, cond := range conditions { + fieldRef := canonicalValue(cond[0]) + value := fields[fieldRef] + matched := matchesCondition(value, cond) + if logic == "or" { + matches = matches || matched + } else { + matches = matches && matched + } + } + if matches { + result = append(result, item) + } + } + return result +} + +func normalizeSortConfig(raw []interface{}) []map[string]interface{} { + result := make([]map[string]interface{}, 0, len(raw)) + for _, item := range raw { + if m, ok := item.(map[string]interface{}); ok { + entry := map[string]interface{}{} + if field, _ := m["field"].(string); field != "" { + entry["field"] = field + } else if field, _ := m["field_name"].(string); field != "" { + entry["field"] = field + } + if desc, ok := m["desc"].(bool); ok { + entry["desc"] = desc + } + result = append(result, entry) + } + } + return result +} + +func sortRecords(items []map[string]interface{}, sortConfig []interface{}) []map[string]interface{} { + normalized := normalizeSortConfig(sortConfig) + if len(normalized) == 0 { + return items + } + sorted := append([]map[string]interface{}{}, items...) + sort.SliceStable(sorted, func(i, j int) bool { + leftFields, _ := sorted[i]["fields"].(map[string]interface{}) + rightFields, _ := sorted[j]["fields"].(map[string]interface{}) + for _, spec := range normalized { + fieldRef, _ := spec["field"].(string) + desc, _ := spec["desc"].(bool) + cmp := compareScalar(leftFields[fieldRef], rightFields[fieldRef]) + if cmp == 0 { + continue + } + if desc { + return cmp > 0 + } + return cmp < 0 + } + return false + }) + return sorted +} + +func sleepBetweenBatches(index int, total int) { + if index < total-1 { + time.Sleep(600 * time.Millisecond) + } +} + +// ── Dashboard Block data_config normalization & validation ─────────── + +func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} { + if cfg == nil { + return nil + } + out := cloneMap(cfg) + // series[].rollup → 大写 + if arr, ok := out["series"].([]interface{}); ok { + for i, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + if r, ok := m["rollup"].(string); ok && r != "" { + m["rollup"] = strings.ToUpper(strings.TrimSpace(r)) + } + arr[i] = m + } + } + out["series"] = arr + } + // group_by.sort 的 type/order → 小写 + if gb, ok := out["group_by"].([]interface{}); ok { + for i, g := range gb { + if m, ok := g.(map[string]interface{}); ok { + if md, ok := m["mode"].(string); ok { + m["mode"] = strings.ToLower(strings.TrimSpace(md)) + } + if sub, ok := m["sort"].(map[string]interface{}); ok { + if t, ok := sub["type"].(string); ok { + sub["type"] = strings.ToLower(strings.TrimSpace(t)) + } + if o, ok := sub["order"].(string); ok { + sub["order"] = strings.ToLower(strings.TrimSpace(o)) + } + m["sort"] = sub + } + gb[i] = m + } + } + out["group_by"] = gb + } + return out +} + +func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []string { + var errs []string + // table_name 必填 + if tn, _ := cfg["table_name"].(string); strings.TrimSpace(tn) == "" { + errs = append(errs, "缺少必填字段 table_name") + } + // series 与 count_all 互斥且必有其一 + _, hasSeries := cfg["series"] + _, hasCountAll := cfg["count_all"] + if !(hasSeries || hasCountAll) { + errs = append(errs, "series 与 count_all 二选一,至少提供其一") + } + if hasSeries && hasCountAll { + errs = append(errs, "series 与 count_all 互斥,不可同时存在") + } + // series 校验 + if hasSeries { + arr, ok := cfg["series"].([]interface{}) + if !ok || len(arr) == 0 { + errs = append(errs, "series 必须是非空数组") + } else { + // rollup 支持:SUM / MAX / MIN / AVERAGE(不支持 COUNTA;计数请使用 count_all) + allowed := map[string]bool{"SUM": true, "MAX": true, "MIN": true, "AVERAGE": true} + for i, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + errs = append(errs, fmt.Sprintf("series[%d] 必须是对象", i)) + continue + } + fn, _ := m["field_name"].(string) + if strings.TrimSpace(fn) == "" { + errs = append(errs, fmt.Sprintf("series[%d].field_name 不能为空", i)) + } + r, _ := m["rollup"].(string) + r = strings.ToUpper(strings.TrimSpace(r)) + if !allowed[r] { + errs = append(errs, fmt.Sprintf("series[%d].rollup 不在允许枚举内: %s", i, r)) + } + } + } + } + // group_by 最多 2 个,字段名必填,sort 合法 + if gb, ok := cfg["group_by"].([]interface{}); ok { + if len(gb) > 2 { + errs = append(errs, "group_by 最多支持 2 个维度") + } + for i, g := range gb { + m, ok := g.(map[string]interface{}) + if !ok { + errs = append(errs, fmt.Sprintf("group_by[%d] 必须是对象", i)) + continue + } + fn, _ := m["field_name"].(string) + if strings.TrimSpace(fn) == "" { + errs = append(errs, fmt.Sprintf("group_by[%d].field_name 不能为空", i)) + } + if sub, ok := m["sort"].(map[string]interface{}); ok { + t, _ := sub["type"].(string) + t = strings.ToLower(strings.TrimSpace(t)) + o, _ := sub["order"].(string) + o = strings.ToLower(strings.TrimSpace(o)) + if t != "group" && t != "value" && t != "view" { + errs = append(errs, fmt.Sprintf("group_by[%d].sort.type 仅支持 group|value|view", i)) + } + if o != "asc" && o != "desc" { + errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 仅支持 asc|desc", i)) + } + } + } + } + // filter 基本结构 + if f, ok := cfg["filter"].(map[string]interface{}); ok { + conj := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", f["conjunction"]))) + if conj == "" { + conj = "and" + } + if conj != "and" && conj != "or" { + errs = append(errs, "filter.conjunction 仅支持 and|or") + } + if conds, ok := f["conditions"].([]interface{}); ok { + allowedOps := map[string]bool{"is": true, "isnot": true, "contains": true, "doesnotcontain": true, "isempty": true, "isnotempty": true, "isgreater": true, "isgreaterequal": true, "isless": true, "islessequal": true} + for i, it := range conds { + m, ok := it.(map[string]interface{}) + if !ok { + errs = append(errs, fmt.Sprintf("filter.conditions[%d] 必须是对象", i)) + continue + } + fn, _ := m["field_name"].(string) + if strings.TrimSpace(fn) == "" { + errs = append(errs, fmt.Sprintf("filter.conditions[%d].field_name 不能为空", i)) + } + op, _ := m["operator"].(string) + key := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(op), " ", "")) + if !allowedOps[key] { + errs = append(errs, fmt.Sprintf("filter.conditions[%d].operator 不支持: %s", i, op)) + } + if key != "isempty" && key != "isnotempty" { + if _, has := m["value"]; !has { + errs = append(errs, fmt.Sprintf("filter.conditions[%d].value 缺失", i)) + } + } + } + } + } + return errs +} + +func formatDataConfigErrors(errs []string) error { + if len(errs) == 0 { + return nil + } + return fmt.Errorf("data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(errs, "\n- ")) +} diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go new file mode 100644 index 00000000..61806c4a --- /dev/null +++ b/shortcuts/base/helpers_test.go @@ -0,0 +1,452 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "os" + "reflect" + "strings" + "testing" + "time" +) + +func TestParseHelpers(t *testing.T) { + tmpDir := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd err=%v", err) + } + defer func() { _ = os.Chdir(cwd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir err=%v", err) + } + tmp, err := os.CreateTemp(".", "base-json-*.json") + if err != nil { + t.Fatalf("temp file err=%v", err) + } + if _, err := tmp.WriteString(`{"name":"from-file"}`); err != nil { + t.Fatalf("write temp file err=%v", err) + } + _ = tmp.Close() + obj, err := parseJSONObject(`{"name":"demo"}`, "json") + if err != nil || obj["name"] != "demo" { + t.Fatalf("obj=%v err=%v", obj, err) + } + if _, err := parseJSONObject(`[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") { + t.Fatalf("err=%v", err) + } + obj, err = parseJSONObject("@"+tmp.Name(), "json") + if err != nil || obj["name"] != "from-file" { + t.Fatalf("file obj=%v err=%v", obj, err) + } + arr, err := parseJSONArray(`[1,2]`, "items") + if err != nil || len(arr) != 2 { + t.Fatalf("arr=%v err=%v", arr, err) + } + if _, err := parseJSONArray(`{"a":1}`, "items"); err == nil || !strings.Contains(err.Error(), "invalid JSON array") { + t.Fatalf("err=%v", err) + } + list, err := parseStringListFlexible("a, b, ,c", "fields") + if err != nil || !reflect.DeepEqual(list, []string{"a", "b", "c"}) { + t.Fatalf("list=%v err=%v", list, err) + } + list, err = parseStringListFlexible(`["x","y"]`, "fields") + if err != nil || !reflect.DeepEqual(list, []string{"x", "y"}) { + t.Fatalf("list=%v err=%v", list, err) + } + if _, err := parseStringListFlexible(`[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") { + t.Fatalf("err=%v", err) + } + if _, err := parseJSONValue("{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") { + t.Fatalf("err=%v", err) + } + if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) { + t.Fatalf("parseStringList mismatch") + } +} + +func TestMapHelpers(t *testing.T) { + dst := map[string]interface{}{"style": map[string]interface{}{"type": "number"}} + src := map[string]interface{}{"style": map[string]interface{}{"formatter": "0.00"}, "name": "Amount"} + merged := deepMergeMaps(dst, src) + style := merged["style"].(map[string]interface{}) + if style["type"] != "number" || style["formatter"] != "0.00" || merged["name"] != "Amount" { + t.Fatalf("merged=%v", merged) + } + cloned := cloneMap(merged) + cloned["name"] = "Changed" + if merged["name"] != "Amount" { + t.Fatalf("clone modified source: %v", merged) + } +} + +func TestResolveFieldTypeSpecAndNormalization(t *testing.T) { + spec, err := resolveFieldTypeSpec("currency") + if err != nil || spec.Type != "number" { + t.Fatalf("spec=%v err=%v", spec, err) + } + if _, ok := spec.Extra["style"]; !ok { + t.Fatalf("spec=%v", spec) + } + spec, err = resolveFieldTypeSpec("multi-select") + if err != nil || spec.Type != "select" || spec.Extra["multiple"] != true { + t.Fatalf("spec=%v err=%v", spec, err) + } + spec, err = resolveFieldTypeSpec("two_way_link") + if err != nil || spec.Type != "link" || spec.Extra["bidirectional"] != true { + t.Fatalf("spec=%v err=%v", spec, err) + } + if _, err := resolveFieldTypeSpec("unknown"); err == nil || !strings.Contains(err.Error(), "unsupported field type") { + t.Fatalf("err=%v", err) + } + if normalizeFieldTypeName(" text ") != "text" { + t.Fatalf("normalizeFieldTypeName failed") + } + if normalizeViewTypeName(" Kanban ") != "kanban" { + t.Fatalf("normalizeViewTypeName failed") + } + if normalizeViewTypeName("Custom") != "Custom" { + t.Fatalf("normalizeViewTypeName should preserve unknown values") + } + options := normalizeSelectOptions([]interface{}{"A", map[string]interface{}{"name": "B", "hue": "blue"}, 1}) + if len(options) != 2 { + t.Fatalf("options=%v", options) + } +} + +func TestBuildFieldBody(t *testing.T) { + if _, err := buildFieldBody("Name", "text", nil, "", "", true, false); err == nil || !strings.Contains(err.Error(), "primary") { + t.Fatalf("err=%v", err) + } + if _, err := buildFieldBody("Name", "text", nil, "", "", false, true); err == nil || !strings.Contains(err.Error(), "hidden") { + t.Fatalf("err=%v", err) + } + body, err := buildFieldBody("Amount", "number", map[string]interface{}{"precision": 2}, "currency", "", false, false) + if err != nil || body["type"] != "number" { + t.Fatalf("body=%v err=%v", body, err) + } + style := body["style"].(map[string]interface{}) + if style["type"] != "currency" || toInt(style["precision"]) != 2 { + t.Fatalf("style=%v", style) + } + body, err = buildFieldBody("Status", "multi-select", map[string]interface{}{"options": []interface{}{"Todo", map[string]interface{}{"name": "Done", "hue": "green"}}, "multiple": true}, "", "", false, false) + if err != nil || body["multiple"] != true { + t.Fatalf("body=%v err=%v", body, err) + } + if len(body["options"].([]interface{})) != 2 { + t.Fatalf("options=%v", body["options"]) + } + body, err = buildFieldBody("Owner", "user", map[string]interface{}{"multiple": false}, "", "", false, false) + if err != nil || body["multiple"] != false { + t.Fatalf("body=%v err=%v", body, err) + } + body, err = buildFieldBody("Relation", "link", map[string]interface{}{"table_id": "tbl_target", "back_field_name": "Back"}, "", "", false, false) + if err != nil || body["link_table"] != "tbl_target" || body["bidirectional"] != true || body["bidirectional_link_field_name"] != "Back" { + t.Fatalf("body=%v err=%v", body, err) + } + body, err = buildFieldBody("Expr", "formula", map[string]interface{}{"formula_expression": "1+1"}, "", "", false, false) + if err != nil || body["expression"] != "1+1" { + t.Fatalf("body=%v err=%v", body, err) + } +} + +func TestBuildTableFieldBodies(t *testing.T) { + fields, err := buildTableFieldBodies(`[{"name":"Name","type":"text"}]`, "") + if err != nil || len(fields) != 1 { + t.Fatalf("fields=%v err=%v", fields, err) + } + fields, err = buildTableFieldBodies("", `[["Name","text"],["Amount","currency"]]`) + if err != nil || len(fields) != 2 { + t.Fatalf("fields=%v err=%v", fields, err) + } + if _, err := buildTableFieldBodies("", `[["Name"]]`); err == nil || !strings.Contains(err.Error(), "must be [name, type]") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseV3Helpers(t *testing.T) { + if baseV3Path("/bases/", "app_1", "/tables/", "tbl_1") != "/open-apis/base/v3/bases/app_1/tables/tbl_1" { + t.Fatalf("baseV3Path mismatch") + } + if baseV3Path("bases", "app_1", "tables", "tbl/1", "fields", "fld?1", "views", "视图 1") != "/open-apis/base/v3/bases/app_1/tables/tbl%2F1/fields/fld%3F1/views/%E8%A7%86%E5%9B%BE%201" { + t.Fatalf("baseV3Path encode mismatch") + } + if toInt("42") != 42 || toInt(7.0) != 7 { + t.Fatalf("toInt mismatch") + } + if !reflect.DeepEqual(toStringSlice([]interface{}{"a", "b", 1}), []string{"a", "b"}) { + t.Fatalf("toStringSlice mismatch") + } +} + +func TestRecordAndChunkHelpers(t *testing.T) { + records, err := normalizeRecordInputs(`[{"record_id":"rec_1","fields":{"Name":"Alice"}},{"Name":"Bob"}]`) + if err != nil || len(records) != 2 { + t.Fatalf("records=%v err=%v", records, err) + } + if _, err := normalizeRecordInputs(`[1]`); err == nil || !strings.Contains(err.Error(), "must be an object") { + t.Fatalf("err=%v", err) + } + if len(chunkRecords(records, 1)) != 2 || len(chunkStringIDs([]string{"a", "b", "c"}, 2)) != 2 { + t.Fatalf("chunk helpers mismatch") + } +} + +func TestResolveAndSimplifyHelpers(t *testing.T) { + fields := []map[string]interface{}{{"id": "fld_1", "name": "Name", "type": "text"}, {"field_id": "fld_2", "field_name": "Age", "type": "number", "multiple": true}} + tables := []map[string]interface{}{{"id": "tbl_1", "name": "Orders"}} + views := []map[string]interface{}{{"id": "vew_1", "name": "Main", "type": "grid"}} + if field, err := resolveFieldRef(fields, "Age"); err != nil || fieldID(field) != "fld_2" { + t.Fatalf("field=%v err=%v", field, err) + } + if table, err := resolveTableRef(tables, "tbl_1"); err != nil || tableNameFromMap(table) != "Orders" { + t.Fatalf("table=%v err=%v", table, err) + } + if view, err := resolveViewRef(views, "Main"); err != nil || viewID(view) != "vew_1" { + t.Fatalf("view=%v err=%v", view, err) + } + if _, err := resolveViewRef(views, "Missing"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err=%v", err) + } + simplifiedFields := simplifyFields(fields) + if len(simplifiedFields) != 2 { + t.Fatalf("simplifiedFields=%v", simplifiedFields) + } + simplifiedViews := simplifyViews(views) + if len(simplifiedViews) != 1 { + t.Fatalf("simplifiedViews=%v", simplifiedViews) + } +} + +func TestFilterAndSortHelpers(t *testing.T) { + items := []map[string]interface{}{ + {"record_id": "rec_1", "fields": map[string]interface{}{"Name": "Alice", "Age": 18, "Tags": []interface{}{"a", "b"}}}, + {"record_id": "rec_2", "fields": map[string]interface{}{"Name": "Bob", "Age": 30, "Tags": []interface{}{"c"}}}, + } + selected := selectRecordFields(items, []string{"Name"}) + if selected[0]["record_id"] != "rec_1" { + t.Fatalf("selected=%v", selected) + } + if compareScalar(2, 10) >= 0 || compareScalar("b", "a") <= 0 { + t.Fatalf("compareScalar mismatch") + } + if canonicalValue([]interface{}{"x"}) != "x" || canonicalValue(map[string]interface{}{"text": "hello"}) != "hello" { + t.Fatalf("canonicalValue mismatch") + } + logic, conditions := normalizeFilterConfig(map[string]interface{}{ + "conjunction": "or", + "conditions": []interface{}{map[string]interface{}{"field_name": "Name", "operator": "==", "value": "Alice"}}, + }) + if logic != "or" || len(conditions) != 1 { + t.Fatalf("logic=%s conditions=%v", logic, conditions) + } + filtered := filterRecords(items, map[string]interface{}{ + "logic": "and", + "conditions": []interface{}{ + []interface{}{"Age", ">=", 18}, + []interface{}{"Tags", "intersects", []interface{}{"b"}}, + }, + }) + if len(filtered) != 1 || filtered[0]["record_id"] != "rec_1" { + t.Fatalf("filtered=%v", filtered) + } + sorted := sortRecords(items, []interface{}{map[string]interface{}{"field": "Age", "desc": true}}) + if sorted[0]["record_id"] != "rec_2" { + t.Fatalf("sorted=%v", sorted) + } + if !matchesCondition(nil, []interface{}{"Name", "empty"}) { + t.Fatalf("matchesCondition empty failed") + } +} + +func TestJSONInputHelpers(t *testing.T) { + if got, err := loadJSONInput(`{"name":"demo"}`, "json"); err != nil || got != `{"name":"demo"}` { + t.Fatalf("got=%q err=%v", got, err) + } + if _, err := loadJSONInput("@", "json"); err == nil || !strings.Contains(err.Error(), "file path cannot be empty") { + t.Fatalf("err=%v", err) + } + tmp := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd err=%v", err) + } + defer func() { _ = os.Chdir(cwd) }() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir err=%v", err) + } + emptyPath := "empty.json" + if err := os.WriteFile(emptyPath, []byte(" \n"), 0o644); err != nil { + t.Fatalf("write empty file err=%v", err) + } + if _, err := loadJSONInput("@"+emptyPath, "json"); err == nil || !strings.Contains(err.Error(), "is empty") { + t.Fatalf("err=%v", err) + } + syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7}) + if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") { + t.Fatalf("syntaxErr=%v", syntaxErr) + } + typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"}) + if !strings.Contains(typeErr.Error(), `field "filter_info"`) { + t.Fatalf("typeErr=%v", typeErr) + } +} + +func TestIdentifierAndValueHelpers(t *testing.T) { + if normalizeViewTypeName("") != "" || normalizeViewTypeName(" Gantt ") != "gantt" || normalizeViewTypeName("gallery") != "gallery" || normalizeViewTypeName("calendar") != "calendar" || normalizeViewTypeName("grid") != "grid" { + t.Fatalf("normalizeViewTypeName unexpected") + } + if tableID(map[string]interface{}{"table_id": "tbl_alt"}) != "tbl_alt" { + t.Fatalf("tableID alt key failed") + } + if tableNameFromMap(map[string]interface{}{"table_name": "Orders"}) != "Orders" { + t.Fatalf("tableName alt key failed") + } + if viewID(map[string]interface{}{"view_id": "vew_alt"}) != "vew_alt" { + t.Fatalf("viewID alt key failed") + } + if viewName(map[string]interface{}{"view_name": "Main"}) != "Main" { + t.Fatalf("viewName alt key failed") + } + if viewType(map[string]interface{}{"view_type": "grid"}) != "grid" { + t.Fatalf("viewType alt key failed") + } + if !valueEmpty(nil) || !valueEmpty(" ") || !valueEmpty([]interface{}{}) || !valueEmpty(map[string]interface{}{}) { + t.Fatalf("valueEmpty empty cases failed") + } + if valueEmpty(0) { + t.Fatalf("valueEmpty should keep numeric zero as non-empty") + } +} + +func TestConditionHelpers(t *testing.T) { + if matchesCondition("x", []interface{}{"Name"}) { + t.Fatalf("short condition should be false") + } + cases := []struct { + name string + value interface{} + cond []interface{} + want bool + }{ + {"eq", 1.0, []interface{}{"Age", "==", 1.0}, true}, + {"neq", 1.0, []interface{}{"Age", "!=", 2.0}, true}, + {"gt", 3.0, []interface{}{"Age", ">", 2.0}, true}, + {"gte", 3.0, []interface{}{"Age", ">=", 3.0}, true}, + {"lt", 1.0, []interface{}{"Age", "<", 2.0}, true}, + {"lte", 1.0, []interface{}{"Age", "<=", 1.0}, true}, + {"empty", " ", []interface{}{"Name", "empty"}, true}, + {"non_empty", "Alice", []interface{}{"Name", "non_empty"}, true}, + {"intersects", []interface{}{"a", "b"}, []interface{}{"Tags", "intersects", []interface{}{"c", "b"}}, true}, + {"disjoint", []interface{}{"a", "b"}, []interface{}{"Tags", "disjoint", []interface{}{"c", "d"}}, true}, + {"unknown", "Alice", []interface{}{"Name", "contains", "A"}, false}, + } + for _, tt := range cases { + if got := matchesCondition(tt.value, tt.cond); got != tt.want { + t.Fatalf("%s got=%v want=%v", tt.name, got, tt.want) + } + } +} + +func TestSleepBetweenBatches(t *testing.T) { + start := time.Now() + sleepBetweenBatches(0, 1) + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Fatalf("unexpected sleep for last batch: %v", elapsed) + } + start = time.Now() + sleepBetweenBatches(0, 2) + if elapsed := time.Since(start); elapsed < 550*time.Millisecond { + t.Fatalf("expected sleep between batches, got %v", elapsed) + } +} + +func TestResolveFieldTypeSpecMoreAliases(t *testing.T) { + cases := []struct { + input string + wantType string + check func(fieldTypeSpec) bool + }{ + {"", "", func(spec fieldTypeSpec) bool { return false }}, + {"progress", "number", func(spec fieldTypeSpec) bool { + return spec.Extra["style"].(map[string]interface{})["type"] == "progress" + }}, + {"rating", "number", func(spec fieldTypeSpec) bool { return spec.Extra["style"].(map[string]interface{})["type"] == "rating" }}, + {"single-select", "select", func(spec fieldTypeSpec) bool { return spec.Extra["multiple"] == false }}, + {"group-chat", "user", func(spec fieldTypeSpec) bool { return spec.Extra["multiple"] == true }}, + {"auto-number", "auto_number", func(spec fieldTypeSpec) bool { _, ok := spec.Extra["style"]; return ok }}, + {"created-time", "created_at", func(spec fieldTypeSpec) bool { + return spec.Extra["style"].(map[string]interface{})["format"] == "yyyy/MM/dd" + }}, + {"modified_time", "updated_at", func(spec fieldTypeSpec) bool { + return spec.Extra["style"].(map[string]interface{})["format"] == "yyyy/MM/dd" + }}, + } + if _, err := resolveFieldTypeSpec(cases[0].input); err == nil || !strings.Contains(err.Error(), "cannot be empty") { + t.Fatalf("err=%v", err) + } + for _, tt := range cases[1:] { + spec, err := resolveFieldTypeSpec(tt.input) + if err != nil || spec.Type != tt.wantType || !tt.check(spec) { + t.Fatalf("input=%s spec=%v err=%v", tt.input, spec, err) + } + } +} + +func TestNamedSpecAndSortHelpers(t *testing.T) { + specs, err := parseNamedTypeSpecs(`[["Name","text"],["Amount","number"]]`, "fields") + if err != nil || len(specs) != 2 || specs[1].Type != "number" { + t.Fatalf("specs=%v err=%v", specs, err) + } + if _, err := parseNamedTypeSpecs(`{}`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON array") { + t.Fatalf("err=%v", err) + } + if _, err := parseNamedTypeSpecs(`[["Name"]]`, "fields"); err == nil || !strings.Contains(err.Error(), "must be [name, type]") { + t.Fatalf("err=%v", err) + } + if _, err := parseNamedTypeSpecs(`[[1,"text"]]`, "fields"); err == nil || !strings.Contains(err.Error(), "must be [string, string]") { + t.Fatalf("err=%v", err) + } + normalized := normalizeSortConfig([]interface{}{ + map[string]interface{}{"field_name": "Priority", "desc": true}, + map[string]interface{}{"field": "Amount"}, + "ignored", + }) + if len(normalized) != 2 || normalized[0]["field"] != "Priority" || normalized[0]["desc"] != true || normalized[1]["field"] != "Amount" { + t.Fatalf("normalized=%v", normalized) + } +} + +func TestCanonicalSelectAndCompareHelpers(t *testing.T) { + if fieldTypeName(map[string]interface{}{"kind": "text"}) != "" { + t.Fatalf("fieldTypeName fallback mismatch") + } + if got := canonicalValue(map[string]interface{}{"id": "opt_1"}); got != "opt_1" { + t.Fatalf("canonical id=%q", got) + } + if got := canonicalValue(1.5); got != "1.5" { + t.Fatalf("canonical float=%q", got) + } + if got := canonicalValue([]interface{}{"x", "y"}); !strings.Contains(got, "x") || !strings.Contains(got, "y") { + t.Fatalf("canonical array=%q", got) + } + if compareScalar("2", 2.0) != 0 || compareScalar("a", "b") >= 0 { + t.Fatalf("compareScalar mismatch") + } + set := asSet(" Alice ") + if !set["Alice"] || len(set) != 1 { + t.Fatalf("set=%v", set) + } + selected := selectRecordFields([]map[string]interface{}{{"record_id": "rec_1", "fields": map[string]interface{}{"Name": "Alice"}}}, nil) + if selected[0]["fields"].(map[string]interface{})["Name"] != "Alice" { + t.Fatalf("selected=%v", selected) + } + if _, err := resolveFieldRef([]map[string]interface{}{{"id": "fld_1", "name": "Name"}}, "Missing"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err=%v", err) + } + if _, err := resolveTableRef([]map[string]interface{}{{"id": "tbl_1", "name": "Orders"}}, "Missing"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err=%v", err) + } +} diff --git a/shortcuts/base/record_delete.go b/shortcuts/base/record_delete.go new file mode 100644 index 00000000..a4e3bffb --- /dev/null +++ b/shortcuts/base/record_delete.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRecordDelete = common.Shortcut{ + Service: "base", + Command: "+record-delete", + Description: "Delete a record by ID", + Risk: "high-risk-write", + Scopes: []string{"base:record:delete"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), recordRefFlag(true)}, + DryRun: dryRunRecordDelete, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeRecordDelete(runtime) + }, +} diff --git a/shortcuts/base/record_get.go b/shortcuts/base/record_get.go new file mode 100644 index 00000000..b54c5466 --- /dev/null +++ b/shortcuts/base/record_get.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRecordGet = common.Shortcut{ + Service: "base", + Command: "+record-get", + Description: "Get a record by ID", + Risk: "read", + Scopes: []string{"base:record:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + recordRefFlag(true), + }, + DryRun: dryRunRecordGet, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeRecordGet(runtime) + }, +} diff --git a/shortcuts/base/record_history_list.go b/shortcuts/base/record_history_list.go new file mode 100644 index 00000000..d9ce8f4e --- /dev/null +++ b/shortcuts/base/record_history_list.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRecordHistoryList = common.Shortcut{ + Service: "base", + Command: "+record-history-list", + Description: "List record change history", + Risk: "read", + Scopes: []string{"base:record:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + recordRefFlag(true), + {Name: "max-version", Type: "int", Desc: "max version for next page"}, + {Name: "page-size", Type: "int", Default: "30", Desc: "pagination size"}, + }, + DryRun: dryRunRecordHistoryList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + params := map[string]interface{}{ + "table_id": baseTableID(runtime), + "record_id": runtime.Str("record-id"), + "page_size": runtime.Int("page-size"), + } + if value := runtime.Int("max-version"); value > 0 { + params["max_version"] = value + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "record_history"), params, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go new file mode 100644 index 00000000..815cfb28 --- /dev/null +++ b/shortcuts/base/record_list.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRecordList = common.Shortcut{ + Service: "base", + Command: "+record-list", + Description: "List records in a table", + Risk: "read", + Scopes: []string{"base:record:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "view-id", Desc: "view ID"}, + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "100", Desc: "pagination size"}, + }, + DryRun: dryRunRecordList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeRecordList(runtime) + }, +} diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go new file mode 100644 index 00000000..280b1c58 --- /dev/null +++ b/shortcuts/base/record_ops.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + params := map[string]interface{}{"offset": offset, "limit": limit} + if viewID := runtime.Str("view-id"); viewID != "" { + params["view_id"] = viewID + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records"). + Params(params). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)) +} + +func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("record_id", runtime.Str("record-id")) +} + +func dryRunRecordUpsert(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, _ := parseJSONObject(runtime.Str("json"), "json") + if recordID := runtime.Str("record-id"); recordID != "" { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("record_id", recordID) + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)) +} + +func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("record_id", runtime.Str("record-id")) +} + +func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{ + "table_id": baseTableID(runtime), + "record_id": runtime.Str("record-id"), + "page_size": runtime.Int("page-size"), + } + if value := runtime.Int("max-version"); value > 0 { + params["max_version"] = value + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/record_history"). + Params(params). + Set("base_token", runtime.Str("base-token")) +} + +func validateRecordJSON(runtime *common.RuntimeContext) error { + return nil +} + +func executeRecordList(runtime *common.RuntimeContext) error { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + params := map[string]interface{}{"offset": offset, "limit": limit} + if viewID := runtime.Str("view-id"); viewID != "" { + params["view_id"] = viewID + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records"), params, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil +} + +func executeRecordGet(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil +} + +func executeRecordUpsert(runtime *common.RuntimeContext) error { + body, err := parseJSONObject(runtime.Str("json"), "json") + if err != nil { + return err + } + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + if recordID := runtime.Str("record-id"); recordID != "" { + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseToken, "tables", tableIDValue, "records", recordID), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"record": data, "updated": true}, nil) + return nil + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "records"), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"record": data, "created": true}, nil) + return nil +} + +func executeRecordDelete(runtime *common.RuntimeContext) error { + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "record_id": runtime.Str("record-id")}, nil) + return nil +} diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go new file mode 100644 index 00000000..68959421 --- /dev/null +++ b/shortcuts/base/record_upload_attachment.go @@ -0,0 +1,261 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + baseAttachmentUploadMaxFileSize = 20 * 1024 * 1024 + baseAttachmentParentType = "bitable_file" +) + +var BaseRecordUploadAttachment = common.Shortcut{ + Service: "base", + Command: "+record-upload-attachment", + Description: "Upload a local file to a Base attachment field and write it into the target record", + Risk: "write", + Scopes: []string{"base:record:update", "base:field:read", "docs:document.media:upload"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + recordRefFlag(true), + fieldRefFlag(true), + {Name: "file", Desc: "local file path (max 20MB)", Required: true}, + {Name: "name", Desc: "attachment file name (default: local file name)"}, + }, + DryRun: dryRunRecordUploadAttachment, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeRecordUploadAttachment(runtime) + }, +} + +func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + filePath := runtime.Str("file") + fileName := strings.TrimSpace(runtime.Str("name")) + if fileName == "" { + fileName = filepath.Base(filePath) + } + return common.NewDryRunAPI(). + Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array"). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). + Desc("[1] Read target field and ensure it is an attachment field"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("field_id", runtime.Str("field-id")). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + Desc("[2] Read current record to preserve existing attachments in the target cell"). + Set("record_id", runtime.Str("record-id")). + POST("/open-apis/drive/v1/medias/upload_all"). + Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": baseAttachmentParentType, + "parent_node": runtime.Str("base-token"), + "file": "@" + filePath, + }). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token"). + Body(map[string]interface{}{ + "": []interface{}{ + map[string]interface{}{ + "file_token": "", + "name": "", + "deprecated_set_attachment": true, + }, + map[string]interface{}{ + "file_token": "", + "name": fileName, + "deprecated_set_attachment": true, + }, + }, + }) +} + +func executeRecordUploadAttachment(runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + safeFilePath, err := validate.SafeInputPath(filePath) + if err != nil { + return output.ErrValidation("unsafe file path: %s", err) + } + filePath = safeFilePath + + fileInfo, err := os.Stat(filePath) + if err != nil { + return output.ErrValidation("file not found: %s", filePath) + } + if fileInfo.Size() > baseAttachmentUploadMaxFileSize { + return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileInfo.Size())/1024/1024) + } + + fileName := strings.TrimSpace(runtime.Str("name")) + if fileName == "" { + fileName = filepath.Base(filePath) + } + + field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id")) + if err != nil { + return err + } + if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" { + return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized) + } + + record, err := fetchBaseRecord(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("record-id")) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field)) + + attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size()) + if err != nil { + return err + } + + attachments, err := mergeRecordAttachments(record, fieldName(field), attachment) + if err != nil { + return err + } + + body := map[string]interface{}{ + fieldName(field): attachments, + } + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{ + "record": data, + "attachment": attachment, + "attachments": attachments, + "updated": true, + }, nil) + return nil +} + +func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fieldRef string) (map[string]interface{}, error) { + return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil) +} + +func fetchBaseRecord(runtime *common.RuntimeContext, baseToken, tableIDValue, recordID string) (map[string]interface{}, error) { + return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "records", recordID), nil, nil) +} + +func mergeRecordAttachments(record map[string]interface{}, fieldName string, uploaded map[string]interface{}) ([]interface{}, error) { + fields, _ := record["fields"].(map[string]interface{}) + if fields == nil { + return []interface{}{uploaded}, nil + } + current, exists := fields[fieldName] + if !exists || util.IsNil(current) { + return []interface{}{uploaded}, nil + } + items, ok := current.([]interface{}) + if !ok { + return nil, output.ErrValidation("record field %q has unexpected attachment payload type %T", fieldName, current) + } + merged := make([]interface{}, 0, len(items)+1) + for _, item := range items { + attachment, ok := item.(map[string]interface{}) + if !ok { + return nil, output.ErrValidation("record field %q contains unexpected attachment item type %T", fieldName, item) + } + merged = append(merged, normalizeAttachmentForPatch(attachment)) + } + merged = append(merged, uploaded) + return merged, nil +} + +func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]interface{} { + normalized := map[string]interface{}{} + if fileToken, _ := attachment["file_token"].(string); fileToken != "" { + normalized["file_token"] = fileToken + } + if name, _ := attachment["name"].(string); name != "" { + normalized["name"] = name + } + if mimeType, _ := attachment["mime_type"].(string); mimeType != "" { + normalized["mime_type"] = mimeType + } + if size, ok := attachment["size"]; ok && !util.IsNil(size) { + normalized["size"] = size + } + if imageWidth, ok := attachment["image_width"]; ok && !util.IsNil(imageWidth) { + normalized["image_width"] = imageWidth + } + if imageHeight, ok := attachment["image_height"]; ok && !util.IsNil(imageHeight) { + normalized["image_height"] = imageHeight + } + normalized["deprecated_set_attachment"] = true + return normalized +} + +func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, output.ErrValidation("cannot open file: %v", err) + } + defer f.Close() + + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", baseAttachmentParentType) + fd.AddField("parent_node", baseToken) + fd.AddField("size", fmt.Sprintf("%d", fileSize)) + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/medias/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return nil, err + } + return nil, output.ErrNetwork("upload failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) + } + + code, _ := util.ToFloat64(result["code"]) + if code != 0 { + msg, _ := result["msg"].(string) + return nil, output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + if fileToken == "" { + return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + } + + attachment := map[string]interface{}{ + "file_token": fileToken, + "name": fileName, + "deprecated_set_attachment": true, + } + return attachment, nil +} diff --git a/shortcuts/base/record_upsert.go b/shortcuts/base/record_upsert.go new file mode 100644 index 00000000..0ff68309 --- /dev/null +++ b/shortcuts/base/record_upsert.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRecordUpsert = common.Shortcut{ + Service: "base", + Command: "+record-upsert", + Description: "Create or update a record", + Risk: "write", + Scopes: []string{"base:record:create", "base:record:update"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + recordRefFlag(false), + {Name: "json", Desc: "record JSON object", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateRecordJSON(runtime) + }, + DryRun: dryRunRecordUpsert, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeRecordUpsert(runtime) + }, +} diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go new file mode 100644 index 00000000..fea4ebd7 --- /dev/null +++ b/shortcuts/base/shortcuts.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all base shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + BaseTableList, + BaseTableGet, + BaseTableCreate, + BaseTableUpdate, + BaseTableDelete, + BaseFieldList, + BaseFieldGet, + BaseFieldCreate, + BaseFieldUpdate, + BaseFieldDelete, + BaseFieldSearchOptions, + BaseViewList, + BaseViewGet, + BaseViewCreate, + BaseViewDelete, + BaseViewGetFilter, + BaseViewSetFilter, + BaseViewGetGroup, + BaseViewSetGroup, + BaseViewGetSort, + BaseViewSetSort, + BaseViewGetTimebar, + BaseViewSetTimebar, + BaseViewGetCard, + BaseViewSetCard, + BaseViewRename, + BaseRecordList, + BaseRecordGet, + BaseRecordUpsert, + BaseRecordUploadAttachment, + BaseRecordDelete, + BaseRecordHistoryList, + BaseBaseGet, + BaseBaseCopy, + BaseBaseCreate, + BaseRoleCreate, + BaseRoleDelete, + BaseRoleUpdate, + BaseRoleList, + BaseRoleGet, + BaseAdvpermEnable, + BaseAdvpermDisable, + BaseWorkflowList, + BaseWorkflowGet, + BaseWorkflowCreate, + BaseWorkflowUpdate, + BaseWorkflowEnable, + BaseWorkflowDisable, + BaseDataQuery, + BaseFormCreate, + BaseFormDelete, + BaseFormsList, + BaseFormUpdate, + BaseFormGet, + BaseFormQuestionsCreate, + BaseFormQuestionsDelete, + BaseFormQuestionsUpdate, + BaseFormQuestionsList, + BaseDashboardList, + BaseDashboardGet, + BaseDashboardCreate, + BaseDashboardUpdate, + BaseDashboardDelete, + BaseDashboardBlockList, + BaseDashboardBlockGet, + BaseDashboardBlockCreate, + BaseDashboardBlockUpdate, + BaseDashboardBlockDelete, + } +} diff --git a/shortcuts/base/table_create.go b/shortcuts/base/table_create.go new file mode 100644 index 00000000..3bb65a8a --- /dev/null +++ b/shortcuts/base/table_create.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseTableCreate = common.Shortcut{ + Service: "base", + Command: "+table-create", + Description: "Create a table and optional fields/views", + Risk: "write", + Scopes: []string{"base:table:create", "base:field:read", "base:field:create", "base:field:update", "base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "name", Desc: "table name", Required: true}, + {Name: "view", Desc: "view JSON object/array for create"}, + {Name: "fields", Desc: "field JSON array for create"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateTableCreate(runtime) + }, + DryRun: dryRunTableCreate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCreate(runtime) + }, +} diff --git a/shortcuts/base/table_delete.go b/shortcuts/base/table_delete.go new file mode 100644 index 00000000..58ad5010 --- /dev/null +++ b/shortcuts/base/table_delete.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseTableDelete = common.Shortcut{ + Service: "base", + Command: "+table-delete", + Description: "Delete a table by ID or name", + Risk: "high-risk-write", + Scopes: []string{"base:table:delete"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, + DryRun: dryRunTableDelete, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableDelete(runtime) + }, +} diff --git a/shortcuts/base/table_get.go b/shortcuts/base/table_get.go new file mode 100644 index 00000000..c427b450 --- /dev/null +++ b/shortcuts/base/table_get.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseTableGet = common.Shortcut{ + Service: "base", + Command: "+table-get", + Description: "Get a table by ID or name", + Risk: "read", + Scopes: []string{"base:table:read", "base:field:read", "base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, + DryRun: dryRunTableGet, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableGet(runtime) + }, +} diff --git a/shortcuts/base/table_list.go b/shortcuts/base/table_list.go new file mode 100644 index 00000000..01de1572 --- /dev/null +++ b/shortcuts/base/table_list.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseTableList = common.Shortcut{ + Service: "base", + Command: "+table-list", + Description: "List tables in a base", + Risk: "read", + Scopes: []string{"base:table:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "50", Desc: "pagination limit"}, + }, + DryRun: dryRunTableList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableList(runtime) + }, +} diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go new file mode 100644 index 00000000..04482396 --- /dev/null +++ b/shortcuts/base/table_ops.go @@ -0,0 +1,216 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dryRunTableList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 100) + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables"). + Params(map[string]interface{}{"offset": offset, "limit": limit}). + Set("base_token", runtime.Str("base-token")) +} + +func dryRunTableGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")) +} + +func dryRunTableCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables"). + Body(map[string]interface{}{"name": runtime.Str("name")}). + Set("base_token", runtime.Str("base-token")) +} + +func dryRunTableUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id"). + Body(map[string]interface{}{"name": runtime.Str("name")}). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")) +} + +func dryRunTableDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")) +} + +func validateTableCreate(runtime *common.RuntimeContext) error { + return nil +} + +func executeTableList(runtime *common.RuntimeContext) error { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 100) + tables, total, err := listAllTables(runtime, runtime.Str("base-token"), offset, limit) + if err != nil { + return err + } + if total == 0 { + total = len(tables) + } + items := make([]interface{}, 0, len(tables)) + for _, table := range tables { + items = append(items, map[string]interface{}{"table_id": tableID(table), "table_name": tableNameFromMap(table)}) + } + runtime.Out(map[string]interface{}{"items": items, "offset": offset, "limit": limit, "count": len(items), "total": total}, nil) + return nil +} + +func executeTableGet(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := runtime.Str("table-id") + table, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue), nil, nil) + if err != nil { + return err + } + fields, err := listEveryField(runtime, baseToken, tableIDValue) + if err != nil { + return err + } + views, err := listEveryView(runtime, baseToken, tableIDValue) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{ + "table": table, + "fields": simplifyFields(fields), + "views": simplifyViews(views), + }, nil) + return nil +} + +func executeTableCreate(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + created, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables"), nil, map[string]interface{}{"name": runtime.Str("name")}) + if err != nil { + return err + } + result := map[string]interface{}{"table": created} + tableIDValue := tableID(created) + if tableIDValue != "" && runtime.Str("fields") != "" { + fieldItems, err := parseJSONArray(runtime.Str("fields"), "fields") + if err != nil { + return err + } + defaultFields, err := listEveryField(runtime, baseToken, tableIDValue) + if err != nil { + return err + } + createdFields := []interface{}{} + for idx, item := range fieldItems { + body, ok := item.(map[string]interface{}) + if !ok { + return fmt.Errorf("--fields item %d must be an object", idx+1) + } + if idx == 0 && len(defaultFields) > 0 { + fieldData, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldID(defaultFields[0])), nil, body) + if err != nil { + return err + } + createdFields = append(createdFields, fieldData) + continue + } + fieldData, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields"), nil, body) + if err != nil { + return err + } + createdFields = append(createdFields, fieldData) + } + result["fields"] = createdFields + } + if tableIDValue != "" && runtime.Str("view") != "" { + viewItems, err := parseObjectList(runtime.Str("view"), "view") + if err != nil { + return err + } + createdViews := []interface{}{} + for _, body := range viewItems { + viewData, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "views"), nil, body) + if err != nil { + return err + } + createdViews = append(createdViews, viewData) + } + result["views"] = createdViews + } + runtime.Out(result, nil) + return nil +} + +func listEveryField(runtime *common.RuntimeContext, baseToken, tableID string) ([]map[string]interface{}, error) { + const pageLimit = 100 + offset := 0 + items := []map[string]interface{}{} + for { + batch, total, err := listAllFields(runtime, baseToken, tableID, offset, pageLimit) + if err != nil { + return nil, err + } + items = append(items, batch...) + if len(batch) == 0 || len(batch) < pageLimit || (total > 0 && len(items) >= total) { + break + } + offset += len(batch) + } + return items, nil +} + +func listEveryView(runtime *common.RuntimeContext, baseToken, tableID string) ([]map[string]interface{}, error) { + const pageLimit = 100 + offset := 0 + items := []map[string]interface{}{} + for { + batch, total, err := listAllViews(runtime, baseToken, tableID, offset, pageLimit) + if err != nil { + return nil, err + } + items = append(items, batch...) + if len(batch) == 0 || len(batch) < pageLimit || (total > 0 && len(items) >= total) { + break + } + offset += len(batch) + } + return items, nil +} + +func executeTableUpdate(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := runtime.Str("table-id") + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseToken, "tables", tableIDValue), nil, map[string]interface{}{"name": runtime.Str("name")}) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"table": data, "updated": true}, nil) + return nil +} + +func executeTableDelete(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := runtime.Str("table-id") + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", tableIDValue), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "table_id": tableIDValue, "table_name": tableIDValue}, nil) + return nil +} diff --git a/shortcuts/base/table_update.go b/shortcuts/base/table_update.go new file mode 100644 index 00000000..12d453e4 --- /dev/null +++ b/shortcuts/base/table_update.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseTableUpdate = common.Shortcut{ + Service: "base", + Command: "+table-update", + Description: "Rename a table by ID or name", + Risk: "write", + Scopes: []string{"base:table:update"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "name", Desc: "new table name", Required: true}, + }, + DryRun: dryRunTableUpdate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableUpdate(runtime) + }, +} diff --git a/shortcuts/base/view_create.go b/shortcuts/base/view_create.go new file mode 100644 index 00000000..3722e41d --- /dev/null +++ b/shortcuts/base/view_create.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewCreate = common.Shortcut{ + Service: "base", + Command: "+view-create", + Description: "Create one or more views", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "json", Desc: "view JSON object/array", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewCreate(runtime) + }, + DryRun: dryRunViewCreate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewCreate(runtime) + }, +} diff --git a/shortcuts/base/view_delete.go b/shortcuts/base/view_delete.go new file mode 100644 index 00000000..5db39902 --- /dev/null +++ b/shortcuts/base/view_delete.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewDelete = common.Shortcut{ + Service: "base", + Command: "+view-delete", + Description: "Delete a view by ID or name", + Risk: "high-risk-write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewDelete, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewDelete(runtime) + }, +} diff --git a/shortcuts/base/view_get.go b/shortcuts/base/view_get.go new file mode 100644 index 00000000..635c57cb --- /dev/null +++ b/shortcuts/base/view_get.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGet = common.Shortcut{ + Service: "base", + Command: "+view-get", + Description: "Get a view by ID or name", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGet, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGet(runtime) + }, +} diff --git a/shortcuts/base/view_get_card.go b/shortcuts/base/view_get_card.go new file mode 100644 index 00000000..10fa43c7 --- /dev/null +++ b/shortcuts/base/view_get_card.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGetCard = common.Shortcut{ + Service: "base", + Command: "+view-get-card", + Description: "Get view card configuration", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetCard, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGetProperty(runtime, "card", "card") + }, +} diff --git a/shortcuts/base/view_get_filter.go b/shortcuts/base/view_get_filter.go new file mode 100644 index 00000000..60ef0efe --- /dev/null +++ b/shortcuts/base/view_get_filter.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGetFilter = common.Shortcut{ + Service: "base", + Command: "+view-get-filter", + Description: "Get view filter configuration", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetFilter, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGetProperty(runtime, "filter", "filter") + }, +} diff --git a/shortcuts/base/view_get_group.go b/shortcuts/base/view_get_group.go new file mode 100644 index 00000000..c201786f --- /dev/null +++ b/shortcuts/base/view_get_group.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGetGroup = common.Shortcut{ + Service: "base", + Command: "+view-get-group", + Description: "Get view group configuration", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetGroup, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGetProperty(runtime, "group", "group") + }, +} diff --git a/shortcuts/base/view_get_sort.go b/shortcuts/base/view_get_sort.go new file mode 100644 index 00000000..99c7797d --- /dev/null +++ b/shortcuts/base/view_get_sort.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGetSort = common.Shortcut{ + Service: "base", + Command: "+view-get-sort", + Description: "Get view sort configuration", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetSort, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGetProperty(runtime, "sort", "sort") + }, +} diff --git a/shortcuts/base/view_get_timebar.go b/shortcuts/base/view_get_timebar.go new file mode 100644 index 00000000..7575b50f --- /dev/null +++ b/shortcuts/base/view_get_timebar.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGetTimebar = common.Shortcut{ + Service: "base", + Command: "+view-get-timebar", + Description: "Get view timebar configuration", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetTimebar, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGetProperty(runtime, "timebar", "timebar") + }, +} diff --git a/shortcuts/base/view_list.go b/shortcuts/base/view_list.go new file mode 100644 index 00000000..30fba37b --- /dev/null +++ b/shortcuts/base/view_list.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewList = common.Shortcut{ + Service: "base", + Command: "+view-list", + Description: "List views in a table", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "100", Desc: "pagination size"}, + }, + DryRun: dryRunViewList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewList(runtime) + }, +} diff --git a/shortcuts/base/view_ops.go b/shortcuts/base/view_ops.go new file mode 100644 index 00000000..53211cf1 --- /dev/null +++ b/shortcuts/base/view_ops.go @@ -0,0 +1,256 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/url" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dryRunViewBase(runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("view_id", runtime.Str("view-id")) +} + +func dryRunViewList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + return dryRunViewBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/views"). + Params(map[string]interface{}{"offset": offset, "limit": limit}) +} + +func dryRunViewGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id") +} + +func dryRunViewCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + api := dryRunViewBase(runtime) + bodyList, err := parseObjectList(runtime.Str("json"), "json") + if err != nil || len(bodyList) == 0 { + return api.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/views") + } + for _, body := range bodyList { + api.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/views").Body(body) + } + return api +} + +func dryRunViewDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewBase(runtime). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id") +} + +func dryRunViewGetProperty(runtime *common.RuntimeContext, segment string) *common.DryRunAPI { + return dryRunViewBase(runtime). + GET(fmt.Sprintf("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/%s", url.PathEscape(segment))) +} + +func dryRunViewSetJSONObject(runtime *common.RuntimeContext, segment string) *common.DryRunAPI { + body, _ := parseJSONObject(runtime.Str("json"), "json") + return dryRunViewBase(runtime). + PUT(fmt.Sprintf("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/%s", url.PathEscape(segment))). + Body(body) +} + +func dryRunViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapper string) *common.DryRunAPI { + raw, err := parseJSONValue(runtime.Str("json"), "json") + if err != nil { + raw = nil + } + return dryRunViewBase(runtime). + PUT(fmt.Sprintf("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/%s", url.PathEscape(segment))). + Body(wrapViewPropertyBody(raw, wrapper)) +} + +func dryRunViewGetFilter(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewGetProperty(runtime, "filter") +} + +func dryRunViewSetFilter(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewSetJSONObject(runtime, "filter") +} + +func dryRunViewGetGroup(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewGetProperty(runtime, "group") +} + +func dryRunViewSetGroup(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewSetWrapped(runtime, "group", "group_config") +} + +func dryRunViewGetSort(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewGetProperty(runtime, "sort") +} + +func dryRunViewSetSort(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewSetWrapped(runtime, "sort", "sort_config") +} + +func dryRunViewGetTimebar(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewGetProperty(runtime, "timebar") +} + +func dryRunViewSetTimebar(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewSetJSONObject(runtime, "timebar") +} + +func dryRunViewGetCard(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewGetProperty(runtime, "card") +} + +func dryRunViewSetCard(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewSetJSONObject(runtime, "card") +} + +func dryRunViewRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewBase(runtime). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id"). + Body(map[string]interface{}{"name": runtime.Str("name")}) +} + +func wrapViewPropertyBody(raw interface{}, key string) interface{} { + if items, ok := raw.([]interface{}); ok { + return map[string]interface{}{key: items} + } + return raw +} + +func validateViewCreate(runtime *common.RuntimeContext) error { + return nil +} + +func validateViewJSONObject(runtime *common.RuntimeContext) error { + return nil +} + +func validateViewJSONValue(runtime *common.RuntimeContext) error { + return nil +} + +func executeViewList(runtime *common.RuntimeContext) error { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + views, total, err := listAllViews(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit) + if err != nil { + return err + } + if total == 0 { + total = len(views) + } + runtime.Out(map[string]interface{}{"items": simplifyViews(views), "offset": offset, "limit": limit, "count": len(views), "total": total}, nil) + return nil +} + +func executeViewGet(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"view": data}, nil) + return nil +} + +func executeViewCreate(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewItems, err := parseObjectList(runtime.Str("json"), "json") + if err != nil { + return err + } + created := []interface{}{} + for _, body := range viewItems { + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "views"), nil, body) + if err != nil { + return err + } + created = append(created, data) + } + runtime.Out(map[string]interface{}{"views": created}, nil) + return nil +} + +func executeViewDelete(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "view_id": viewRef, "view_name": viewRef}, nil) + return nil +} + +func executeViewGetProperty(runtime *common.RuntimeContext, segment string, key string) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + data, err := baseV3CallAny(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef, segment), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{key: data}, nil) + return nil +} + +func executeViewSetJSONObject(runtime *common.RuntimeContext, segment string, key string) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + body, err := parseJSONObject(runtime.Str("json"), "json") + if err != nil { + return err + } + data, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef, segment), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{key: data}, nil) + return nil +} + +func executeViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapper string, key string) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + raw, err := parseJSONValue(runtime.Str("json"), "json") + if err != nil { + return err + } + payload := wrapViewPropertyBody(raw, wrapper) + data, err := baseV3CallAny(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef, segment), nil, payload) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{key: data}, nil) + return nil +} + +func executeViewRename(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef), nil, map[string]interface{}{"name": runtime.Str("name")}) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"view": data}, nil) + return nil +} diff --git a/shortcuts/base/view_rename.go b/shortcuts/base/view_rename.go new file mode 100644 index 00000000..22ab08a4 --- /dev/null +++ b/shortcuts/base/view_rename.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewRename = common.Shortcut{ + Service: "base", + Command: "+view-rename", + Description: "Rename a view by ID or name", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "name", Desc: "new view name", Required: true}, + }, + DryRun: dryRunViewRename, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewRename(runtime) + }, +} diff --git a/shortcuts/base/view_set_card.go b/shortcuts/base/view_set_card.go new file mode 100644 index 00000000..416c674b --- /dev/null +++ b/shortcuts/base/view_set_card.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewSetCard = common.Shortcut{ + Service: "base", + Command: "+view-set-card", + Description: "Set view card configuration", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "json", Desc: "card JSON object", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewJSONObject(runtime) + }, + DryRun: dryRunViewSetCard, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewSetJSONObject(runtime, "card", "card") + }, +} diff --git a/shortcuts/base/view_set_filter.go b/shortcuts/base/view_set_filter.go new file mode 100644 index 00000000..ff065fb5 --- /dev/null +++ b/shortcuts/base/view_set_filter.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewSetFilter = common.Shortcut{ + Service: "base", + Command: "+view-set-filter", + Description: "Set view filter configuration", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "json", Desc: "filter JSON object", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewJSONObject(runtime) + }, + DryRun: dryRunViewSetFilter, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewSetJSONObject(runtime, "filter", "filter") + }, +} diff --git a/shortcuts/base/view_set_group.go b/shortcuts/base/view_set_group.go new file mode 100644 index 00000000..9a9f6618 --- /dev/null +++ b/shortcuts/base/view_set_group.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewSetGroup = common.Shortcut{ + Service: "base", + Command: "+view-set-group", + Description: "Set view group configuration", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "json", Desc: "group JSON object/array", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewJSONValue(runtime) + }, + DryRun: dryRunViewSetGroup, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewSetWrapped(runtime, "group", "group_config", "group") + }, +} diff --git a/shortcuts/base/view_set_sort.go b/shortcuts/base/view_set_sort.go new file mode 100644 index 00000000..874473e6 --- /dev/null +++ b/shortcuts/base/view_set_sort.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewSetSort = common.Shortcut{ + Service: "base", + Command: "+view-set-sort", + Description: "Set view sort configuration", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "json", Desc: "sort JSON object/array", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewJSONValue(runtime) + }, + DryRun: dryRunViewSetSort, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewSetWrapped(runtime, "sort", "sort_config", "sort") + }, +} diff --git a/shortcuts/base/view_set_timebar.go b/shortcuts/base/view_set_timebar.go new file mode 100644 index 00000000..d9ba352f --- /dev/null +++ b/shortcuts/base/view_set_timebar.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewSetTimebar = common.Shortcut{ + Service: "base", + Command: "+view-set-timebar", + Description: "Set view timebar configuration", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "json", Desc: "timebar JSON object", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewJSONObject(runtime) + }, + DryRun: dryRunViewSetTimebar, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewSetJSONObject(runtime, "timebar", "timebar") + }, +} diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go new file mode 100644 index 00000000..0bba5bbe --- /dev/null +++ b/shortcuts/base/workflow_create.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowCreate = common.Shortcut{ + Service: "base", + Command: "+workflow-create", + Description: "Create a new workflow in a base", + Risk: "write", + Scopes: []string{"base:workflow:create"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}; or @path/to/file.json for large definitions`, Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + raw, err := loadJSONInput(runtime.Str("json"), "json") + if err != nil { + return err + } + if _, err := parseJSONObject(raw, "json"); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + var body map[string]interface{} + if raw, err := loadJSONInput(runtime.Str("json"), "json"); err == nil { + body, _ = parseJSONObject(raw, "json") + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/workflows"). + Body(body). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + raw, err := loadJSONInput(runtime.Str("json"), "json") + if err != nil { + return err + } + body, err := parseJSONObject(raw, "json") + if err != nil { + return err + } + data, err := baseV3Call(runtime, "POST", + baseV3Path("bases", runtime.Str("base-token"), "workflows"), + nil, + body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/workflow_disable.go b/shortcuts/base/workflow_disable.go new file mode 100644 index 00000000..7b3e2c3c --- /dev/null +++ b/shortcuts/base/workflow_disable.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowDisable = common.Shortcut{ + Service: "base", + Command: "+workflow-disable", + Description: "Disable a workflow in a base", + Risk: "write", + Scopes: []string{"base:workflow:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("workflow-id")) == "" { + return common.FlagErrorf("--workflow-id must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id/disable"). + Set("base_token", runtime.Str("base-token")). + Set("workflow_id", runtime.Str("workflow-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "PATCH", + baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id"), "disable"), + nil, + map[string]interface{}{}, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/workflow_enable.go b/shortcuts/base/workflow_enable.go new file mode 100644 index 00000000..3bf9e96d --- /dev/null +++ b/shortcuts/base/workflow_enable.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowEnable = common.Shortcut{ + Service: "base", + Command: "+workflow-enable", + Description: "Enable a workflow in a base", + Risk: "write", + Scopes: []string{"base:workflow:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("workflow-id")) == "" { + return common.FlagErrorf("--workflow-id must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id/enable"). + Set("base_token", runtime.Str("base-token")). + Set("workflow_id", runtime.Str("workflow-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "PATCH", + baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id"), "enable"), + nil, + map[string]interface{}{}, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go new file mode 100644 index 00000000..be2fd3f8 --- /dev/null +++ b/shortcuts/base/workflow_execute_test.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestBaseWorkflowExecuteGet(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "My Workflow"}, + }, + }) + if err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--base-token", "app_x", "--workflow-id", "wkf_1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"wkf_1"`) || !strings.Contains(got, `"My Workflow"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseWorkflowExecuteGetWithUserIDType(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "user_id_type=open_id", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "creator": map[string]interface{}{"open_id": "ou_abc"}}, + }, + }) + if err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--base-token", "app_x", "--workflow-id", "wkf_1", "--user-id-type", "open_id"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"ou_abc"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseWorkflowExecuteGetValidate(t *testing.T) { + t.Run("missing base-token", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--workflow-id", "wkf_1"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "base-token") { + t.Fatalf("err=%v", err) + } + }) + t.Run("missing workflow-id", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--base-token", "app_x"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "workflow-id") { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseWorkflowExecuteCreate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_new", "title": "My Workflow"}, + }, + }) + if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"title":"My Workflow","steps":[]}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"wkf_new"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { + t.Run("missing base-token", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--json", `{"title":"x"}`}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "base-token") { + t.Fatalf("err=%v", err) + } + }) + t.Run("invalid json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `not-json`}, factory, stdout) + if err == nil { + t.Fatalf("expected error for invalid json") + } + }) +} + +func TestBaseWorkflowExecuteDisable(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1/disable", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "status": "disabled"}, + }, + }) + if err := runShortcut(t, BaseWorkflowDisable, []string{"+workflow-disable", "--base-token", "app_x", "--workflow-id", "wkf_1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"disabled"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseWorkflowExecuteDisableValidate(t *testing.T) { + t.Run("missing base-token", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowDisable, []string{"+workflow-disable", "--workflow-id", "wkf_1"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "base-token") { + t.Fatalf("err=%v", err) + } + }) + t.Run("missing workflow-id", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowDisable, []string{"+workflow-disable", "--base-token", "app_x"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "workflow-id") { + t.Fatalf("err=%v", err) + } + }) +} diff --git a/shortcuts/base/workflow_get.go b/shortcuts/base/workflow_get.go new file mode 100644 index 00000000..2a7517be --- /dev/null +++ b/shortcuts/base/workflow_get.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowGet = common.Shortcut{ + Service: "base", + Command: "+workflow-get", + Description: "Get a single workflow definition (including steps) from a base", + Risk: "read", + Scopes: []string{"base:workflow:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, + {Name: "user-id-type", Desc: "user ID type for creator/updater fields", Enum: []string{"open_id", "union_id", "user_id"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("workflow-id")) == "" { + return common.FlagErrorf("--workflow-id must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + api := common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). + Set("base_token", runtime.Str("base-token")). + Set("workflow_id", runtime.Str("workflow-id")) + if t := runtime.Str("user-id-type"); t != "" { + api = api.Params(map[string]interface{}{"user_id_type": t}) + } + return api + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + var params map[string]interface{} + if t := runtime.Str("user-id-type"); t != "" { + params = map[string]interface{}{"user_id_type": t} + } + data, err := baseV3Call(runtime, "GET", + baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id")), + params, + nil, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/workflow_list.go b/shortcuts/base/workflow_list.go new file mode 100644 index 00000000..92a40540 --- /dev/null +++ b/shortcuts/base/workflow_list.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowList = common.Shortcut{ + Service: "base", + Command: "+workflow-list", + Description: "List all workflows in a base (auto-paginated)", + Risk: "read", + Scopes: []string{"base:workflow:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "status", Desc: "filter by status", Enum: []string{"enabled", "disabled"}}, + {Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{ + "page_size": runtime.Int("page-size"), + } + if s := runtime.Str("status"); s != "" { + body["status"] = s + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/workflows/list"). + Body(body). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + var allItems []interface{} + pageToken := "" + for { + body := map[string]interface{}{ + "page_size": runtime.Int("page-size"), + } + if pageToken != "" { + body["page_token"] = pageToken + } + if s := runtime.Str("status"); s != "" { + body["status"] = s + } + data, err := baseV3Call(runtime, "POST", + baseV3Path("bases", runtime.Str("base-token"), "workflows", "list"), + nil, + body, + ) + if err != nil { + return err + } + items, _ := data["items"].([]interface{}) + allItems = append(allItems, items...) + hasMore, _ := data["has_more"].(bool) + if !hasMore { + break + } + nextToken, _ := data["page_token"].(string) + if nextToken == "" { + break + } + pageToken = nextToken + } + runtime.Out(map[string]interface{}{ + "items": allItems, + "total": len(allItems), + }, nil) + return nil + }, +} diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go new file mode 100644 index 00000000..0b316e4f --- /dev/null +++ b/shortcuts/base/workflow_update.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowUpdate = common.Shortcut{ + Service: "base", + Command: "+workflow-update", + Description: "Replace a workflow's full definition (title and/or steps) in a base", + Risk: "write", + Scopes: []string{"base:workflow:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, + {Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}; or @path/to/file.json for large definitions`, Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("workflow-id")) == "" { + return common.FlagErrorf("--workflow-id must not be blank") + } + raw, err := loadJSONInput(runtime.Str("json"), "json") + if err != nil { + return err + } + if _, err := parseJSONObject(raw, "json"); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + var body map[string]interface{} + if raw, err := loadJSONInput(runtime.Str("json"), "json"); err == nil { + body, _ = parseJSONObject(raw, "json") + } + return common.NewDryRunAPI(). + PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("workflow_id", runtime.Str("workflow-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + raw, err := loadJSONInput(runtime.Str("json"), "json") + if err != nil { + return err + } + body, err := parseJSONObject(raw, "json") + if err != nil { + return err + } + data, err := baseV3Call(runtime, "PUT", + baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id")), + nil, + body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_agenda.go b/shortcuts/calendar/calendar_agenda.go new file mode 100644 index 00000000..70093c83 --- /dev/null +++ b/shortcuts/calendar/calendar_agenda.go @@ -0,0 +1,294 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "fmt" + "io" + "sort" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const maxInstanceViewSpanSeconds = 40 * 24 * 60 * 60 +const minSplitWindowSeconds = 2 * 60 * 60 + +// Calendar API error codes. +const ( + larkErrCalendarTimeRangeExceeded = 193103 // instance_view query time range exceeds 40-day limit + larkErrCalendarTooManyInstances = 193104 // instance_view returns more than 1000 instances +) + +func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, endTime int64, depth int) ([]map[string]interface{}, error) { + if depth > 10 { + return nil, output.Errorf(output.ExitInternal, "recursion_limit", "too many splits for instance_view") + } + if startTime > endTime { + return nil, nil + } + span := endTime - startTime + if span > maxInstanceViewSpanSeconds { + mid := startTime + span/2 + left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1) + if err != nil { + return nil, err + } + right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1) + if err != nil { + return nil, err + } + return append(left, right...), nil + } + + result, err := runtime.RawAPI("GET", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/instance_view", validate.EncodePathSegment(calendarId)), + map[string]interface{}{ + "start_time": fmt.Sprintf("%d", startTime), + "end_time": fmt.Sprintf("%d", endTime), + }, nil) + if err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", err) + } + + resultMap, _ := result.(map[string]interface{}) + code, _ := util.ToFloat64(resultMap["code"]) + + if code == 0 { + data, _ := resultMap["data"].(map[string]interface{}) + items, _ := data["items"].([]interface{}) + var events []map[string]interface{} + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + events = append(events, m) + } + } + return events, nil + } + + // Error 193103: time range exceeds limit -> split + if int(code) == larkErrCalendarTimeRangeExceeded { + mid := startTime + span/2 + if mid <= startTime { + return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: time range exceeds 40-day limit, please narrow the range") + } + left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1) + if err != nil { + return nil, err + } + right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1) + if err != nil { + return nil, err + } + return append(left, right...), nil + } + + // Error 193104: too many instances -> split + if int(code) == larkErrCalendarTooManyInstances { + if span <= minSplitWindowSeconds { + return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: more than 1000 instances in the time range, please narrow the range") + } + mid := startTime + span/2 + left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1) + if err != nil { + return nil, err + } + right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1) + if err != nil { + return nil, err + } + return append(left, right...), nil + } + + msg, _ := resultMap["msg"].(string) + return nil, output.ErrAPI(int(code), msg, resultMap["error"]) +} + +func dedupeAndSortItems(items []map[string]interface{}) []map[string]interface{} { + seen := make(map[string]bool) + var result []map[string]interface{} + for _, e := range items { + eventId, _ := e["event_id"].(string) + startMap, _ := e["start_time"].(map[string]interface{}) + endMap, _ := e["end_time"].(map[string]interface{}) + startTs, _ := startMap["timestamp"].(string) + endTs, _ := endMap["timestamp"].(string) + key := eventId + "|" + startTs + "|" + endTs + if !seen[key] { + seen[key] = true + result = append(result, e) + } + } + + sort.Slice(result, func(i, j int) bool { + si, _ := result[i]["start_time"].(map[string]interface{}) + sj, _ := result[j]["start_time"].(map[string]interface{}) + ti, _ := si["timestamp"].(string) + tj, _ := sj["timestamp"].(string) + ni, _ := strconv.ParseInt(ti, 10, 64) + nj, _ := strconv.ParseInt(tj, 10, 64) + return ni < nj + }) + + return result +} + +// parseTimeRange parses --start/--end into Unix seconds. +func parseTimeRange(runtime *common.RuntimeContext) (int64, int64, error) { + startInput, endInput := resolveStartEnd(runtime) + + startTime, err := common.ParseTime(startInput) + if err != nil { + return 0, 0, output.ErrValidation("--start: %v", err) + } + endTime, err := common.ParseTime(endInput, "end") + if err != nil { + return 0, 0, output.ErrValidation("--end: %v", err) + } + + startInt, err := strconv.ParseInt(startTime, 10, 64) + if err != nil { + return 0, 0, output.ErrValidation("invalid start time: %v", err) + } + endInt, err := strconv.ParseInt(endTime, 10, 64) + if err != nil { + return 0, 0, output.ErrValidation("invalid end time: %v", err) + } + + return startInt, endInt, nil +} + +var CalendarAgenda = common.Shortcut{ + Service: "calendar", + Command: "+agenda", + Description: "View calendar agenda (defaults to today)", + Risk: "read", + Scopes: []string{"calendar:calendar.event:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "start", Desc: "start time (ISO 8601, default: start of today)"}, + {Name: "end", Desc: "end time (ISO 8601, default: end of start day)"}, + {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + startInt, endInt, err := parseTimeRange(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + calendarId := runtime.Str("calendar-id") + d := common.NewDryRunAPI() + switch calendarId { + case "": + d.Desc("(calendar-id omitted) Will use primary calendar") + calendarId = "" + case "primary": + calendarId = "" + } + return d. + GET("/open-apis/calendar/v4/calendars/:calendar_id/events/instance_view"). + Params(map[string]interface{}{"start_time": fmt.Sprintf("%d", startInt), "end_time": fmt.Sprintf("%d", endInt)}). + Set("calendar_id", calendarId) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + startInt, endInt, err := parseTimeRange(runtime) + if err != nil { + return err + } + calendarId := strings.TrimSpace(runtime.Str("calendar-id")) + if calendarId == "" { + calendarId = PrimaryCalendarIDStr + } + + items, err := fetchInstanceViewRange(ctx, runtime, calendarId, startInt, endInt, 0) + if err != nil { + return err + } + visible := dedupeAndSortItems(items) + + // Filter cancelled + filtered := make([]map[string]interface{}, 0) + for _, e := range visible { + status, _ := e["status"].(string) + if status != "cancelled" { + delete(e, "status") + delete(e, "attendees") + + // Replace timestamp with datetime (RFC3339, device timezone) + if startMap, ok := e["start_time"].(map[string]interface{}); ok { + if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" { + if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { + startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339) + delete(startMap, "timestamp") + } + } + } + if endMap, ok := e["end_time"].(map[string]interface{}); ok { + if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" { + if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { + endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339) + delete(endMap, "timestamp") + } + } + // If datetime is empty (all-day event), adjust date: date -> timestamp(00:00:00 UTC) -> -1s -> date + if dt, _ := endMap["datetime"].(string); dt == "" { + if dateStr, ok := endMap["date"].(string); ok && dateStr != "" { + if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil { + endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02") + } + } + } + } + + filtered = append(filtered, e) + } + } + + runtime.OutFormat(filtered, &output.Meta{Count: len(filtered)}, func(w io.Writer) { + if len(filtered) == 0 { + fmt.Fprintln(w, "No events in this time range.") + return + } + + var rows []map[string]interface{} + for _, e := range filtered { + summary, _ := e["summary"].(string) + if summary == "" { + summary = "(untitled)" + } + summary = common.TruncateStr(summary, 40) + startMap, _ := e["start_time"].(map[string]interface{}) + endMap, _ := e["end_time"].(map[string]interface{}) + startStr, _ := startMap["datetime"].(string) + if startStr == "" { + startStr, _ = startMap["date"].(string) + } + endStr, _ := endMap["datetime"].(string) + if endStr == "" { + endStr, _ = endMap["date"].(string) + } + freeBusyStatus, _ := e["free_busy_status"].(string) + selfRsvpStatus, _ := e["self_rsvp_status"].(string) + eventId, _ := e["event_id"].(string) + rows = append(rows, map[string]interface{}{ + "event_id": eventId, + "summary": summary, + "start": startStr, + "end": endStr, + "free_busy_status": freeBusyStatus, + "self_rsvp_status": selfRsvpStatus, + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d event(s) total\n", len(filtered)) + }) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_create.go b/shortcuts/calendar/calendar_create.go new file mode 100644 index 00000000..329e9d52 --- /dev/null +++ b/shortcuts/calendar/calendar_create.go @@ -0,0 +1,283 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} { + eventData := map[string]interface{}{ + "summary": runtime.Str("summary"), + "description": runtime.Str("description"), + "start_time": map[string]string{"timestamp": startTs}, + "end_time": map[string]string{"timestamp": endTs}, + "attendee_ability": "can_modify_event", + "free_busy_status": "busy", + "reminders": []map[string]int{ + {"minutes": 5}, + }, + } + if rrule := runtime.Str("rrule"); rrule != "" { + eventData["recurrence"] = rrule + } + return eventData +} + +func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]string, error) { + if attendeesStr == "" && currentUserId == "" { + return nil, nil + } + ids := strings.Split(attendeesStr, ",") + uniqueIds := make(map[string]bool) + if currentUserId != "" { + uniqueIds[currentUserId] = true + } + for _, id := range ids { + id = strings.TrimSpace(id) + if id != "" { + uniqueIds[id] = true + } + } + var attendees []map[string]string + for id := range uniqueIds { + switch { + case strings.HasPrefix(id, "oc_"): + attendees = append(attendees, map[string]string{"type": "chat", "chat_id": id}) + case strings.HasPrefix(id, "omm_"): + attendees = append(attendees, map[string]string{"type": "resource", "room_id": id}) + case strings.HasPrefix(id, "ou_"): + attendees = append(attendees, map[string]string{"type": "user", "user_id": id}) + default: + return nil, fmt.Errorf("unsupported attendee id format: %s", id) + } + } + return attendees, nil +} + +var CalendarCreate = common.Shortcut{ + Service: "calendar", + Command: "+create", + Description: "Create a calendar event and optionally invite attendees", + Risk: "write", + Scopes: []string{"calendar:calendar.event:create", "calendar:calendar.event:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "summary", Desc: "event title"}, + {Name: "start", Desc: "start time (ISO 8601)", Required: true}, + {Name: "end", Desc: "end time (ISO 8601)", Required: true}, + {Name: "description", Desc: "event description"}, + {Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"}, + {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, + {Name: "rrule", Desc: "recurrence rule (rfc5545)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + for _, flag := range []string{"summary", "description", "rrule", "calendar-id"} { + if val := runtime.Str(flag); val != "" { + if err := common.RejectDangerousChars("--"+flag, val); err != nil { + return output.ErrValidation(err.Error()) + } + } + } + + if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" { + for _, id := range strings.Split(attendeesStr, ",") { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") { + return output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id) + } + } + } + + if runtime.Str("start") == "" { + return common.FlagErrorf("specify --start (e.g. '2026-03-12T14:00+08:00')") + } + if runtime.Str("end") == "" { + return common.FlagErrorf("specify --end (e.g. '2026-03-12T15:00+08:00')") + } + startTs, err := common.ParseTime(runtime.Str("start")) + if err != nil { + return common.FlagErrorf("--start: %v", err) + } + endTs, err := common.ParseTime(runtime.Str("end"), "end") + if err != nil { + return common.FlagErrorf("--end: %v", err) + } + s, err := strconv.ParseInt(startTs, 10, 64) + if err != nil { + return common.FlagErrorf("invalid start time: %v", err) + } + e, err := strconv.ParseInt(endTs, 10, 64) + if err != nil { + return common.FlagErrorf("invalid end time: %v", err) + } + if e <= s { + return common.FlagErrorf("end time must be after start time") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + calendarId := runtime.Str("calendar-id") + d := common.NewDryRunAPI() + switch calendarId { + case "": + d.Desc("(calendar-id omitted) Will use primary calendar") + calendarId = "" + case "primary": + calendarId = "" + } + startTs, err := common.ParseTime(runtime.Str("start")) + if err != nil { + return common.NewDryRunAPI().Set("error", fmt.Sprintf("--start: %v", err)) + } + endTs, err := common.ParseTime(runtime.Str("end"), "end") + if err != nil { + return common.NewDryRunAPI().Set("error", fmt.Sprintf("--end: %v", err)) + } + eventData := buildEventData(runtime, startTs, endTs) + attendeesStr := runtime.Str("attendee-ids") + if attendeesStr != "" { + // Note: dry-run doesn't network resolve the current user's open_id. + attendees, err := parseAttendees(attendeesStr, "") + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + d.Desc("2-step: create event → add attendees (auto-rollback on failure)"). + POST("/open-apis/calendar/v4/calendars/:calendar_id/events"). + Desc("[1/2] Create event"). + Body(eventData). + POST("/open-apis/calendar/v4/calendars/:calendar_id/events//attendees"). + Desc("[2/2] Add attendees (on failure: auto-delete event)"). + Params(map[string]interface{}{"user_id_type": "open_id"}). + Body(map[string]interface{}{"attendees": attendees, "need_notification": true}) + } else { + d.POST("/open-apis/calendar/v4/calendars/:calendar_id/events"). + Body(eventData) + } + return d.Set("calendar_id", calendarId) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + calendarId := strings.TrimSpace(runtime.Str("calendar-id")) + if calendarId == "" { + calendarId = PrimaryCalendarIDStr + } + + startTs, err := common.ParseTime(runtime.Str("start")) + if err != nil { + return output.ErrValidation("--start: %v", err) + } + endTs, err := common.ParseTime(runtime.Str("end"), "end") + if err != nil { + return output.ErrValidation("--end: %v", err) + } + + eventData := buildEventData(runtime, startTs, endTs) + + // Create event + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)), + nil, eventData) + if err != nil { + return err + } + event, _ := data["event"].(map[string]interface{}) + eventId, _ := event["event_id"].(string) + if eventId == "" { + return output.Errorf(output.ExitAPI, "api_error", "failed to create event: no event_id returned") + } + + // Add attendees if specified + if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" { + currentUserId := "" + if !runtime.IsBot() { + currentUserId = runtime.UserOpenId() + } + attendees, err := parseAttendees(attendeesStr, currentUserId) + if err != nil { + return output.ErrValidation("invalid attendee id: %v", err) + } + + _, err = runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)), + map[string]interface{}{"user_id_type": "open_id"}, + map[string]interface{}{ + "attendees": attendees, + "need_notification": true, + }) + if err != nil { + // Rollback: delete the event + _, rollbackErr := runtime.RawAPI("DELETE", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)), + map[string]interface{}{"need_notification": false}, nil) + if rollbackErr != nil { + return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; rollback also failed, orphan event_id=%s needs manual cleanup", rollbackErr, eventId) + } + return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; event rolled back successfully", err) + } + } + + startMap, _ := event["start_time"].(map[string]interface{}) + endMap, _ := event["end_time"].(map[string]interface{}) + + // Replace timestamp with datetime (RFC3339, device timezone) + if startMap != nil { + if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" { + if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { + startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339) + delete(startMap, "timestamp") + } + } + } + if endMap != nil { + if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" { + if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { + endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339) + delete(endMap, "timestamp") + } + } + // If datetime is empty (all-day event), adjust date: date -> timestamp(00:00:00 UTC) -> -1s -> date + if dt, _ := endMap["datetime"].(string); dt == "" { + if dateStr, ok := endMap["date"].(string); ok && dateStr != "" { + if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil { + endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02") + } + } + } + } + + var startStr, endStr string + if startMap != nil { + startStr, _ = startMap["datetime"].(string) + if startStr == "" { + startStr, _ = startMap["date"].(string) + } + } + if endMap != nil { + endStr, _ = endMap["datetime"].(string) + if endStr == "" { + endStr, _ = endMap["date"].(string) + } + } + + runtime.Out(map[string]interface{}{ + "event_id": eventId, + "summary": event["summary"], + "start": startStr, + "end": endStr, + }, nil) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_freebusy.go b/shortcuts/calendar/calendar_freebusy.go new file mode 100644 index 00000000..f3b9dc9a --- /dev/null +++ b/shortcuts/calendar/calendar_freebusy.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "fmt" + "io" + "strconv" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// parseFreebusyTimeRange parses --start/--end into RFC3339. +func parseFreebusyTimeRange(runtime *common.RuntimeContext) (string, string, error) { + startInput, endInput := resolveStartEnd(runtime) + + startTs, err := common.ParseTime(startInput) + if err != nil { + return "", "", output.ErrValidation("--start: %v", err) + } + endTs, err := common.ParseTime(endInput, "end") + if err != nil { + return "", "", output.ErrValidation("--end: %v", err) + } + + startSec, err := strconv.ParseInt(startTs, 10, 64) + if err != nil { + return "", "", output.ErrValidation("invalid start timestamp: %v", err) + } + endSec, err := strconv.ParseInt(endTs, 10, 64) + if err != nil { + return "", "", output.ErrValidation("invalid end timestamp: %v", err) + } + + timeMin := time.Unix(startSec, 0).Format(time.RFC3339) + timeMax := time.Unix(endSec, 0).Format(time.RFC3339) + return timeMin, timeMax, nil +} + +var CalendarFreebusy = common.Shortcut{ + Service: "calendar", + Command: "+freebusy", + Description: "Query user free/busy and RSVP status", + Risk: "read", + Scopes: []string{"calendar:calendar.free_busy:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "start", Desc: "start time (ISO 8601, default: today)"}, + {Name: "end", Desc: "end time (ISO 8601, default: end of start day)"}, + {Name: "user-id", Desc: "target user open_id (ou_ prefix, default: current user)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + userId := runtime.Str("user-id") + if userId == "" { + userId = runtime.UserOpenId() + } + timeMin, timeMax, err := parseFreebusyTimeRange(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST("/open-apis/calendar/v4/freebusy/list"). + Body(map[string]interface{}{"time_min": timeMin, "time_max": timeMax, "user_id": userId, "need_rsvp_status": true}) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + userId := runtime.Str("user-id") + if userId == "" && runtime.IsBot() { + return common.FlagErrorf("--user-id is required for bot identity") + } + if userId == "" && runtime.UserOpenId() == "" { + return common.FlagErrorf("cannot determine user ID, specify --user-id or ensure you are logged in") + } + if userId != "" { + if _, err := common.ValidateUserID(userId); err != nil { + return err + } + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + userId := runtime.Str("user-id") + if userId == "" { + userId = runtime.UserOpenId() + } + + timeMin, timeMax, err := parseFreebusyTimeRange(runtime) + if err != nil { + return output.ErrValidation("--start/--end: %v", err) + } + + data, err := runtime.CallAPI("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{ + "time_min": timeMin, + "time_max": timeMax, + "user_id": userId, + "need_rsvp_status": true, + }) + if err != nil { + return err + } + items, _ := data["freebusy_list"].([]interface{}) + + runtime.OutFormat(items, &output.Meta{Count: len(items)}, func(w io.Writer) { + if len(items) == 0 { + fmt.Fprintln(w, "No busy periods in this time range.") + return + } + + var rows []map[string]interface{} + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, map[string]interface{}{ + "start": m["start_time"], + "end": m["end_time"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d busy period(s) total\n", len(items)) + }) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_suggestion.go b/shortcuts/calendar/calendar_suggestion.go new file mode 100644 index 00000000..8d151529 --- /dev/null +++ b/shortcuts/calendar/calendar_suggestion.go @@ -0,0 +1,337 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + suggestionPath = "/open-apis/calendar/v4/freebusy/suggestion" + + flagStart = "start" + flagEnd = "end" + flagAttendees = "attendee-ids" + flagEventRrule = "event-rrule" + flagDurationMinutes = "duration-minutes" + flagTimezone = "timezone" + flagExclude = "exclude" +) + +type OpenAPIResponse[T any] struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` + Data T `json:"data,omitempty"` +} + +type SuggestionRequest struct { + SearchStartTime string `json:"search_start_time,omitempty"` + SearchEndTime string `json:"search_end_time,omitempty"` + Timezone string `json:"timezone,omitempty"` + EventRrule string `json:"event_rrule,omitempty"` + DurationMinutes int `json:"duration_minutes,omitempty"` + AttendeeUserIds []string `json:"attendee_user_ids,omitempty"` + AttendeeChatIds []string `json:"attendee_chat_ids,omitempty"` + ExcludedEventTimes []*EventTime `json:"excluded_event_times,omitempty"` +} + +type EventTime struct { + EventStartTime string `json:"event_start_time,omitempty"` + EventEndTime string `json:"event_end_time,omitempty"` + RecommendReason string `json:"recommend_reason,omitempty"` +} + +type SuggestionResponse struct { + Suggestions []*EventTime `json:"suggestions,omitempty"` + AiActionGuidance string `json:"ai_action_guidance,omitempty"` +} + +func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest, error) { + req := &SuggestionRequest{} + + // resolve start and end times specifically for suggestion (default to current time to end of today) + startInput := runtime.Str(flagStart) + if startInput == "" { + startInput = time.Now().Format(time.RFC3339) + } + + timeMin, err := common.ParseTime(startInput) + if err != nil { + return nil, output.ErrValidation("invalid --start: %v", err) + } + minSec, err := strconv.ParseInt(timeMin, 10, 64) + if err != nil { + return nil, output.ErrValidation("invalid start timestamp: %v", err) + } + startTime := time.Unix(minSec, 0) + + endInput := runtime.Str(flagEnd) + if endInput == "" { + // end of start time's day + endOfStartDay := time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 23, 59, 59, 0, startTime.Location()) + endInput = endOfStartDay.Format(time.RFC3339) + } + + timeMax, err := common.ParseTime(endInput, "end") + if err != nil { + return nil, output.ErrValidation("invalid --end: %v", err) + } + // Convert Unix timestamp string back to RFC3339 since the API requires RFC3339 + maxSec, err := strconv.ParseInt(timeMax, 10, 64) + if err != nil { + return nil, output.ErrValidation("invalid end timestamp: %v", err) + } + req.SearchStartTime = startTime.Format(time.RFC3339) + req.SearchEndTime = time.Unix(maxSec, 0).Format(time.RFC3339) + + // Parse combined attendees (auto-split by prefix oc_ for chats) + attendeesStr := runtime.Str(flagAttendees) + if attendeesStr != "" { + parts := strings.Split(attendeesStr, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if strings.HasPrefix(p, "oc_") { + req.AttendeeChatIds = append(req.AttendeeChatIds, p) + } else { + req.AttendeeUserIds = append(req.AttendeeUserIds, p) + } + } + } + + // Fallback joining strategy for current user + if !runtime.IsBot() { + userOpenId := runtime.UserOpenId() + found := false + for _, id := range req.AttendeeUserIds { + if id == userOpenId { + found = true + break + } + } + if !found && userOpenId != "" { + req.AttendeeUserIds = append(req.AttendeeUserIds, userOpenId) + } + } + + eventRrule := runtime.Str(flagEventRrule) + if eventRrule != "" { + req.EventRrule = eventRrule + } + + durationMinutes := runtime.Int(flagDurationMinutes) + if durationMinutes > 0 { + req.DurationMinutes = durationMinutes + } + + timezone := runtime.Str(flagTimezone) + if timezone != "" { + req.Timezone = timezone + } + + excludeStr := runtime.Str(flagExclude) + if excludeStr != "" { + excludeStr = strings.TrimSpace(excludeStr) + var excludedTimes []*EventTime + + ranges := strings.Split(excludeStr, ",") + for _, r := range ranges { + r = strings.TrimSpace(r) + if r == "" { + continue + } + parts := strings.Split(r, "~") + if len(parts) != 2 { + return nil, output.ErrValidation("invalid --exclude format %q, expected 'start~end'", r) + } + startTsStr, err := common.ParseTime(parts[0]) + if err != nil { + return nil, output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err) + } + endTsStr, err := common.ParseTime(parts[1], "end") + if err != nil { + return nil, output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err) + } + startSec, err := strconv.ParseInt(startTsStr, 10, 64) + if err != nil { + return nil, output.ErrValidation("invalid start timestamp in --exclude: %v", err) + } + endSec, err := strconv.ParseInt(endTsStr, 10, 64) + if err != nil { + return nil, output.ErrValidation("invalid end timestamp in --exclude: %v", err) + } + excludedTimes = append(excludedTimes, &EventTime{ + EventStartTime: time.Unix(startSec, 0).Format(time.RFC3339), + EventEndTime: time.Unix(endSec, 0).Format(time.RFC3339), + }) + } + + req.ExcludedEventTimes = excludedTimes + } + + return req, nil +} + +var CalendarSuggestion = common.Shortcut{ + Service: "calendar", + Command: "+suggestion", + Description: "Intelligently suggest available meeting times to simplify scheduling", + Risk: "read", + Scopes: []string{"calendar:calendar.free_busy:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: flagStart, Type: "string", Desc: "search start time (ISO 8601, default: current time)"}, + {Name: flagEnd, Type: "string", Desc: "search end time (ISO 8601, default: end of start day)"}, + {Name: flagAttendees, Type: "string", Desc: "attendee IDs, comma-separated (supports user (open_id) ou_xxx, or chat oc_xxx) ids"}, + {Name: flagEventRrule, Type: "string", Desc: "event recurrence rules"}, + {Name: flagDurationMinutes, Type: "int", Desc: "duration (minutes)"}, + {Name: flagTimezone, Type: "string", Desc: "current time zone"}, + {Name: flagExclude, Type: "string", Desc: "excluded event times (ISO 8601, e.g. '2026-03-19T10:00:00+08:00~2026-03-19T11:00:00+08:00'), comma-separated"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + req, err := buildSuggestionRequest(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST(suggestionPath). + Body(req) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + durationMinutes := runtime.Int(flagDurationMinutes) + if durationMinutes != 0 && (durationMinutes < 1 || durationMinutes > 1440) { + return output.ErrValidation("--duration-minutes must be between 1 and 1440") + } + + for _, flag := range []string{flagEventRrule, flagTimezone} { + if val := runtime.Str(flag); val != "" { + if err := common.RejectDangerousChars("--"+flag, val); err != nil { + return output.ErrValidation(err.Error()) + } + } + } + + if attendeesStr := runtime.Str(flagAttendees); attendeesStr != "" { + for _, id := range strings.Split(attendeesStr, ",") { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") { + return output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id) + } + } + } + + startInput := runtime.Str(flagStart) + if startInput != "" { + if _, err := common.ParseTime(startInput); err != nil { + return output.ErrValidation("invalid start time: %v", err) + } + } + + endInput := runtime.Str(flagEnd) + if endInput != "" { + if _, err := common.ParseTime(endInput, "end"); err != nil { + return output.ErrValidation("invalid end time: %v", err) + } + } + + excludeStr := runtime.Str(flagExclude) + if excludeStr != "" { + excludeStr = strings.TrimSpace(excludeStr) + ranges := strings.Split(excludeStr, ",") + for _, r := range ranges { + r = strings.TrimSpace(r) + if r == "" { + continue + } + parts := strings.Split(r, "~") + if len(parts) != 2 { + return output.ErrValidation("invalid range format in --exclude: %q, expect start~end", r) + } + if _, err := common.ParseTime(parts[0]); err != nil { + return output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err) + } + if _, err := common.ParseTime(parts[1], "end"); err != nil { + return output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err) + } + } + } + + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + req, err := buildSuggestionRequest(runtime) + if err != nil { + return err + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: "POST", + ApiPath: suggestionPath, + Body: req, + }) + if err != nil { + return output.ErrWithHint(output.ExitInternal, "request_fail", "api request fail", err.Error()) + } + + if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices { + return output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody)) + } + + var resp = &OpenAPIResponse[*SuggestionResponse]{} + if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { + return output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error()) + } + + if resp.Code != 0 { + return output.ErrAPI(resp.Code, resp.Msg, resp.Data) + } + + data := resp.Data + var suggestions []*EventTime + var aiGuidance string + if data != nil { + suggestions = data.Suggestions + aiGuidance = data.AiActionGuidance + } + runtime.OutFormat(data, &output.Meta{Count: len(suggestions)}, func(w io.Writer) { + if len(suggestions) == 0 { + fmt.Fprintln(w, "No suggestions available.") + } else { + var rows []map[string]interface{} + for _, item := range suggestions { + rows = append(rows, map[string]interface{}{ + "start": item.EventStartTime, + "end": item.EventEndTime, + "reason": item.RecommendReason, + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d suggestion(s) found\n", len(suggestions)) + } + + if aiGuidance != "" { + fmt.Fprintf(w, "\nAction Guidance: %s\n", aiGuidance) + } + }) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go new file mode 100644 index 00000000..00823f56 --- /dev/null +++ b/shortcuts/calendar/calendar_test.go @@ -0,0 +1,893 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// warmOnce ensures the Lark SDK's internal token cache is populated exactly +// once per test binary. The SDK caches tenant tokens by app credentials, so +// only the very first API call in the process actually hits the token endpoint. +var warmOnce sync.Once + +func warmTokenCache(t *testing.T) { + t.Helper() + warmOnce.Do(func() { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/warm", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + }) + s := common.Shortcut{ + Service: "test", + Command: "+warm", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil) + return err + }, + } + parent := &cobra.Command{Use: "test"} + s.Mount(parent, f) + parent.SetArgs([]string{"+warm"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + parent.Execute() + }) +} + +func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + warmTokenCache(t) + parent := &cobra.Command{Use: "test"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func defaultConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + UserOpenId: "ou_testuser", + } +} + +// --------------------------------------------------------------------------- +// CalendarCreate tests +// --------------------------------------------------------------------------- + +func TestCreate_CreateEventOnly(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_001", + "summary": "Test Meeting", + "start_time": map[string]interface{}{ + "timestamp": "1742515200", + }, + "end_time": map[string]interface{}{ + "timestamp": "1742518800", + }, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Test Meeting", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "evt_001") { + t.Errorf("stdout should contain event_id, got: %s", stdout.String()) + } +} + +func TestCreate_WithAttendees_Success(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_002", + "summary": "Team Sync", + "start_time": map[string]interface{}{ + "timestamp": "1742515200", + }, + "end_time": map[string]interface{}{ + "timestamp": "1742518800", + }, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/events/evt_002/attendees", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Team Sync", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--attendee-ids", "ou_user1,ou_user2,oc_group1", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreate_WithAttendees_APIError_RollsBack(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_003", + "summary": "Bad Attendees", + "start_time": map[string]interface{}{ + "timestamp": "1742515200", + }, + "end_time": map[string]interface{}{ + "timestamp": "1742518800", + }, + }, + }, + }, + }) + // Attendees API returns business error + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/events/evt_003/attendees", + Body: map[string]interface{}{ + "code": 190002, + "msg": "invalid user_id", + }, + }) + // Rollback: delete the event + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/events/evt_003", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Bad Attendees", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--attendee-ids", "ou_invalid", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error for invalid attendees, got nil") + } + if !strings.Contains(err.Error(), "rolled back successfully") && !strings.Contains(err.Error(), "auto-rolled back") { + t.Fatalf("error should mention rollback, got: %v", err) + } +} + +func TestCreate_CreateEvent_APIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{ + "code": 190001, + "msg": "permission denied", + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Denied", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error for API failure, got nil") + } +} + +func TestCreate_EndBeforeStart(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Invalid", + "--start", "2025-03-21T10:00:00+08:00", + "--end", "2025-03-21T09:00:00+08:00", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for end < start, got nil") + } + if !strings.Contains(err.Error(), "end time must be after start time") { + t.Errorf("error should mention end/start, got: %v", err) + } +} + +func TestCreate_ExplicitCalendarId(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_explicit/events", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_004", + "summary": "Explicit Cal", + "start_time": map[string]interface{}{"timestamp": "1742515200"}, + "end_time": map[string]interface{}{"timestamp": "1742518800"}, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Explicit Cal", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_explicit", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreate_NoEventIdReturned(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{}, + }, + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "No ID", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error when no event_id returned, got nil") + } +} + +// --------------------------------------------------------------------------- +// CalendarAgenda tests +// --------------------------------------------------------------------------- + +func TestAgenda_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/events/instance_view", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "event_id": "evt_a1", + "summary": "Morning standup", + "status": "confirmed", + "start_time": map[string]interface{}{ + "timestamp": "1742515200", + }, + "end_time": map[string]interface{}{ + "timestamp": "1742518800", + }, + }, + map[string]interface{}{ + "event_id": "evt_a2", + "summary": "All Day Event", + "status": "confirmed", + "start_time": map[string]interface{}{ + "date": "2025-03-21", + }, + "end_time": map[string]interface{}{ + "date": "2025-03-21", + }, + }, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarAgenda, []string{ + "+agenda", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--format", "prettry", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "evt_a1") { + t.Errorf("stdout should contain event_id, got: %s", stdout.String()) + } +} + +func TestAgenda_EmptyResult(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/events/instance_view", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{}, + }, + }, + }) + + err := mountAndRun(t, CalendarAgenda, []string{ + "+agenda", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var envelope map[string]interface{} + if json.Unmarshal(stdout.Bytes(), &envelope) == nil { + if data, ok := envelope["data"].([]interface{}); ok && len(data) != 0 { + t.Errorf("expected empty data array, got %d items", len(data)) + } + } +} + +func TestAgenda_FiltersCancelledEvents(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/events/instance_view", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "event_id": "evt_confirmed", + "summary": "Active Event", + "status": "confirmed", + "start_time": map[string]interface{}{"timestamp": "1742515200"}, + "end_time": map[string]interface{}{"timestamp": "1742518800"}, + }, + map[string]interface{}{ + "event_id": "evt_cancelled", + "summary": "Cancelled Event", + "status": "cancelled", + "start_time": map[string]interface{}{"timestamp": "1742519000"}, + "end_time": map[string]interface{}{"timestamp": "1742522600"}, + }, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarAgenda, []string{ + "+agenda", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "evt_confirmed") { + t.Errorf("stdout should contain confirmed event, got: %s", out) + } + if strings.Contains(out, "evt_cancelled") { + t.Errorf("stdout should not contain cancelled event, got: %s", out) + } +} + +func TestAgenda_ExplicitCalendarId(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/calendar/v4/calendars/cal_my/events/instance_view", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{}, + }, + }, + }) + + err := mountAndRun(t, CalendarAgenda, []string{ + "+agenda", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--calendar-id", "cal_my", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// CalendarFreebusy tests +// --------------------------------------------------------------------------- + +func TestFreebusy_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/list", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "freebusy_list": []interface{}{ + map[string]interface{}{ + "start_time": "2025-03-21T10:00:00+08:00", + "end_time": "2025-03-21T11:00:00+08:00", + }, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarFreebusy, []string{ + "+freebusy", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--user-id", "ou_someone", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "start_time") { + t.Errorf("stdout should contain freebusy data, got: %s", stdout.String()) + } +} + +func TestFreebusy_BotWithoutUser_Fails(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarFreebusy, []string{ + "+freebusy", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for bot without --user-id, got nil") + } + if !strings.Contains(err.Error(), "--user-id is required") { + t.Errorf("error should mention --user-id requirement, got: %v", err) + } +} + +func TestFreebusy_APIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/list", + Body: map[string]interface{}{ + "code": 190001, + "msg": "permission denied", + }, + }) + + err := mountAndRun(t, CalendarFreebusy, []string{ + "+freebusy", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--user-id", "ou_someone", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error for API failure, got nil") + } +} + +// --------------------------------------------------------------------------- +// CalendarSuggestion tests +// --------------------------------------------------------------------------- + +func TestSuggestion_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/suggestion", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "suggestions": []interface{}{ + map[string]interface{}{ + "event_start_time": "2025-03-21T10:00:00+08:00", + "event_end_time": "2025-03-21T11:00:00+08:00", + "recommend_reason": "everyone is free", + }, + }, + "ai_action_guidance": "book it", + }, + }, + }) + + // 正常执行 + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--attendee-ids", "ou_user1,oc_chat1", + "--event-rrule", "FREQ=DAILY;BYDAY=MO", + "--duration-minutes", "60", + "--timezone", "Asia/Shanghai", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "2025-03-21T10:00:00+08:00") { + t.Errorf("stdout should contain start time, got: %s", out) + } + if !strings.Contains(out, "everyone is free") { + t.Errorf("stdout should contain reason, got: %s", out) + } + if !strings.Contains(out, `"ai_action_guidance": "book it"`) { + t.Errorf("stdout should contain guidance, got: %s", out) + } +} + +func TestSuggestion_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--attendee-ids", "ou_user1,oc_chat1", + "--event-rrule", "FREQ=DAILY;BYDAY=MO", + "--duration-minutes", "60", + "--timezone", "Asia/Shanghai", + "--dry-run", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSuggestion_Pretty(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/suggestion", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "suggestions": []interface{}{ + map[string]interface{}{ + "event_start_time": "2025-03-21T10:00:00+08:00", + "event_end_time": "2025-03-21T11:00:00+08:00", + "recommend_reason": "everyone is free", + }, + }, + "ai_action_guidance": "book it", + }, + }, + }) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--attendee-ids", "ou_user1,oc_chat1", + "--event-rrule", "FREQ=DAILY;BYDAY=MO", + "--duration-minutes", "60", + "--timezone", "Asia/Shanghai", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSuggestion_DefaultTime(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/suggestion", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "suggestions": []interface{}{ + map[string]interface{}{ + "event_start_time": "2025-03-21T10:00:00+08:00", + "event_end_time": "2025-03-21T11:00:00+08:00", + "recommend_reason": "everyone is free", + }, + }, + "ai_action_guidance": "book it", + }, + }, + }) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSuggestion_ExcludeTime(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/suggestion", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "suggestions": []interface{}{ + map[string]interface{}{ + "event_start_time": "2025-03-21T10:00:00+08:00", + "event_end_time": "2025-03-21T11:00:00+08:00", + "recommend_reason": "everyone is free", + }, + }, + "ai_action_guidance": "book it", + }, + }, + }) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2025-03-21T14:00:00+08:00", + "--end", "2025-03-21T18:00:00+08:00", + "--duration-minutes", "30", + "--timezone", "Asia/Shanghai", + "--exclude", "2025-03-21T14:00:00+08:00~2025-03-21T14:30:00+08:00,2025-03-21T15:00:00+08:00~2025-03-21T15:30:00+08:00", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSuggestion_InvalidAttendee_Fails(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--attendee-ids", "invalid_id", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for invalid attendee id, got nil") + } + if !strings.Contains(err.Error(), "invalid attendee id format") { + t.Errorf("error should mention attendee id format, got: %v", err) + } +} + +func TestSuggestion_InvalidExclude_Fails(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--exclude", "2025-03-21", // missing ~ + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for invalid exclude format, got nil") + } + if !strings.Contains(err.Error(), "invalid range format in --exclude") { + t.Errorf("error should mention exclude format, got: %v", err) + } +} + +func TestSuggestion_APIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/suggestion", + Body: map[string]interface{}{ + "code": 190001, + "msg": "permission denied", + }, + }) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error for API failure, got nil") + } +} + +// --------------------------------------------------------------------------- +// helpers unit tests +// --------------------------------------------------------------------------- + +func TestDedupeAndSortItems(t *testing.T) { + items := []map[string]interface{}{ + {"event_id": "e1", "start_time": map[string]interface{}{"timestamp": "200"}, "end_time": map[string]interface{}{"timestamp": "300"}}, + {"event_id": "e2", "start_time": map[string]interface{}{"timestamp": "100"}, "end_time": map[string]interface{}{"timestamp": "150"}}, + // duplicate of e1 + {"event_id": "e1", "start_time": map[string]interface{}{"timestamp": "200"}, "end_time": map[string]interface{}{"timestamp": "300"}}, + } + + result := dedupeAndSortItems(items) + + if len(result) != 2 { + t.Fatalf("expected 2 items after dedup, got %d", len(result)) + } + id0, _ := result[0]["event_id"].(string) + id1, _ := result[1]["event_id"].(string) + if id0 != "e2" || id1 != "e1" { + t.Errorf("expected order [e2, e1], got [%s, %s]", id0, id1) + } +} + +func TestResolveStartEnd_Defaults(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("start", "", "") + cmd.Flags().String("end", "", "") + cmd.ParseFlags(nil) + + rt := &common.RuntimeContext{Cmd: cmd} + start, end := resolveStartEnd(rt) + + if start == "" { + t.Error("start should not be empty") + } + if end != start { + t.Errorf("end should equal start when both unset, got start=%q end=%q", start, end) + } +} + +func TestResolveStartEnd_ExplicitValues(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("start", "", "") + cmd.Flags().String("end", "", "") + cmd.ParseFlags(nil) + cmd.Flags().Set("start", "2025-03-01") + cmd.Flags().Set("end", "2025-03-15") + + rt := &common.RuntimeContext{Cmd: cmd} + start, end := resolveStartEnd(rt) + + if start != "2025-03-01" { + t.Errorf("start = %q, want 2025-03-01", start) + } + if end != "2025-03-15" { + t.Errorf("end = %q, want 2025-03-15", end) + } +} + +// --------------------------------------------------------------------------- +// Shortcuts() registration test +// --------------------------------------------------------------------------- + +func TestShortcuts_Returns4(t *testing.T) { + shortcuts := Shortcuts() + if len(shortcuts) != 4 { + t.Fatalf("expected 4 shortcuts, got %d", len(shortcuts)) + } + + names := map[string]bool{} + for _, s := range shortcuts { + names[s.Command] = true + } + for _, want := range []string{"+agenda", "+create", "+freebusy", "+suggestion"} { + if !names[want] { + t.Errorf("missing shortcut %s", want) + } + } +} + +func TestShortcuts_AllHaveScopes(t *testing.T) { + for _, s := range Shortcuts() { + if s.Scopes == nil { + t.Errorf("shortcut %s: Scopes is nil", s.Command) + } + } +} diff --git a/shortcuts/calendar/helpers.go b/shortcuts/calendar/helpers.go new file mode 100644 index 00000000..a905b4a1 --- /dev/null +++ b/shortcuts/calendar/helpers.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "time" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + PrimaryCalendarIDStr = "primary" +) + +// resolveStartEnd returns (startInput, endInput) from flags with defaults. +// --start defaults to today's date, --end defaults to start date (will be resolved to end-of-day by caller). +func resolveStartEnd(runtime *common.RuntimeContext) (string, string) { + startInput := runtime.Str("start") + if startInput == "" { + startInput = time.Now().Format("2006-01-02") + } + endInput := runtime.Str("end") + if endInput == "" { + endInput = startInput + } + return startInput, endInput +} diff --git a/shortcuts/calendar/shortcuts.go b/shortcuts/calendar/shortcuts.go new file mode 100644 index 00000000..5f2ca92b --- /dev/null +++ b/shortcuts/calendar/shortcuts.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all calendar shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + CalendarAgenda, + CalendarCreate, + CalendarFreebusy, + CalendarSuggestion, + } +} diff --git a/shortcuts/common/common.go b/shortcuts/common/common.go new file mode 100644 index 00000000..36ffabd9 --- /dev/null +++ b/shortcuts/common/common.go @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" +) + +// RequireConfirmation blocks high-risk-write operations unless --yes is passed. +func RequireConfirmation(risk string, yes bool, action string) error { + if risk != "high-risk-write" || yes { + return nil + } + return output.ErrWithHint(output.ExitValidation, "unsafe_operation_blocked", + fmt.Sprintf("high-risk operation requires confirmation: %s", action), + "add --yes to confirm") +} + +func FormatSize(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } + if bytes < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(bytes)/1024) + } + if bytes < 1024*1024*1024 { + return fmt.Sprintf("%.1f MB", float64(bytes)/1024/1024) + } + return fmt.Sprintf("%.1f GB", float64(bytes)/1024/1024/1024) +} + +func MaskToken(token string) string { + if len(token) < 2 { + return "***" + } + if len(token) <= 8 { + return token[:2] + "***" + } + return token[:4] + "..." + token[len(token)-4:] +} + +// ParseTime converts time expressions to Unix seconds string. +// +// Optional hint: "end" makes day-granularity inputs snap to 23:59:59 instead of 00:00:00. +// +// ParseTime("2026-01-01") → 2026-01-01 00:00:00 +// ParseTime("2026-01-01", "end") → 2026-01-01 23:59:59 +// +// Supported formats: ISO 8601 (with or without time/timezone), date-only, Unix timestamp. +func ParseTime(input string, hint ...string) (string, error) { + input = strings.TrimSpace(input) + isEnd := len(hint) > 0 && hint[0] == "end" + + // snapDay aligns to start-of-day or end-of-day based on hint. + snapDay := func(t time.Time) time.Time { + if isEnd { + return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()) + } + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + } + + // ISO 8601 with timezone (precise) + tzFormats := []string{ + time.RFC3339, + "2006-01-02T15:04Z07:00", + "2006-01-02T15:04:05Z07:00", + } + for _, f := range tzFormats { + if t, err := time.Parse(f, input); err == nil { + return fmt.Sprintf("%d", t.Unix()), nil + } + } + // ISO 8601 without timezone — with time component (precise) + preciseFormats := []string{ + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02T15:04", + "2006-01-02 15:04", + } + for _, f := range preciseFormats { + if t, err := time.ParseInLocation(f, input, time.Local); err == nil { + return fmt.Sprintf("%d", t.Unix()), nil + } + } + // Date-only (day-granularity) + if t, err := time.ParseInLocation("2006-01-02", input, time.Local); err == nil { + return fmt.Sprintf("%d", snapDay(t).Unix()), nil + } + // Unix timestamp (precise, passed through as-is) — must be purely numeric + var ts int64 + if n, err := fmt.Sscanf(input, "%d", &ts); err == nil && n == 1 && ts > 0 && fmt.Sprintf("%d", ts) == input { + return input, nil + } + return "", fmt.Errorf("cannot parse time %q (supported: ISO 8601 e.g. 2026-01-01 / 2026-01-01T15:04:05+08:00, Unix timestamp)", input) +} + +// FormatTimeWithSeconds converts Unix seconds/ms string to local time string with seconds precision. +func FormatTimeWithSeconds(ts interface{}) string { + if ts == nil { + return "" + } + s := fmt.Sprintf("%v", ts) + if s == "" { + return "" + } + var n int64 + fmt.Sscanf(s, "%d", &n) + if n == 0 { + return s + } + if n > 1e12 { + n = n / 1000 + } + t := time.Unix(n, 0) + return t.Local().Format("2006-01-02 15:04:05") +} + +// FormatTime converts Unix seconds/ms string to local time string. +func FormatTime(ts interface{}) string { + if ts == nil { + return "" + } + s := fmt.Sprintf("%v", ts) + if s == "" { + return "" + } + var n int64 + fmt.Sscanf(s, "%d", &n) + if n == 0 { + return s + } + // Detect ms vs seconds + if n > 1e12 { + n = n / 1000 + } + t := time.Unix(n, 0) + return t.Local().Format("2006-01-02 15:04") +} + +// SplitCSV 解析逗号分隔的列表,忽略空项并去除空格 +func SplitCSV(input string) []string { + if input == "" { + return nil + } + parts := strings.Split(input, ",") + var result []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +// CheckApiError checks if API result is an error and prints it to w. +func CheckApiError(w io.Writer, result interface{}, action string) bool { + if resultMap, ok := result.(map[string]interface{}); ok { + code, _ := util.ToFloat64(resultMap["code"]) + if code != 0 { + msg, _ := resultMap["msg"].(string) + output.PrintError(w, fmt.Sprintf("%s: [%.0f] %s", action, code, msg)) + return true + } + } + return false +} + +// HandleApiResult checks for network/API errors and returns the "data" field. +func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) { + if err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err) + } + resultMap, _ := result.(map[string]interface{}) + code, _ := util.ToFloat64(resultMap["code"]) + if code != 0 { + msg, _ := resultMap["msg"].(string) + larkCode := int(code) + fullMsg := fmt.Sprintf("%s: [%d] %s", action, larkCode, msg) + return nil, output.ErrAPI(larkCode, fullMsg, resultMap["error"]) + } + data, _ := resultMap["data"].(map[string]interface{}) + return data, nil +} + +// TruncateStr truncates s to at most n runes. +func TruncateStr(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} diff --git a/shortcuts/common/common_test.go b/shortcuts/common/common_test.go new file mode 100644 index 00000000..7c8f02e9 --- /dev/null +++ b/shortcuts/common/common_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "testing" + "time" +) + +func TestParseTimeISO(t *testing.T) { + s, err := ParseTime("2026-01-15") + if err != nil { + t.Fatalf("ParseTime(date) error: %v", err) + } + ts, _ := strconv.ParseInt(s, 10, 64) + parsed := time.Unix(ts, 0) + if parsed.Year() != 2026 || parsed.Month() != 1 || parsed.Day() != 15 { + t.Errorf("ParseTime(2026-01-15) = %v", parsed) + } +} + +func TestParseTimeUnix(t *testing.T) { + ts := fmt.Sprintf("%d", time.Now().Unix()) + s, err := ParseTime(ts) + if err != nil { + t.Fatalf("ParseTime(unix) error: %v", err) + } + if s != ts { + t.Errorf("ParseTime(%q) = %q, want pass-through", ts, s) + } +} + +func TestParseTimeRejectsRelative(t *testing.T) { + for _, input := range []string{"today", "tomorrow", "yesterday", "now", "this_week", "+3d", "-1w", "+2h", "-30m", "last_7_days"} { + t.Run(input, func(t *testing.T) { + _, err := ParseTime(input) + if err == nil { + t.Errorf("ParseTime(%q) should return error, but got nil", input) + } + }) + } +} + +func TestParseTimeEndHint(t *testing.T) { + s, err := ParseTime("2026-03-15", "end") + if err != nil { + t.Fatalf("ParseTime(date, end) error: %v", err) + } + ts, _ := strconv.ParseInt(s, 10, 64) + parsed := time.Unix(ts, 0) + if parsed.Hour() != 23 || parsed.Minute() != 59 || parsed.Second() != 59 { + t.Errorf("ParseTime(2026-03-15, end) = %v, want 23:59:59", parsed) + } +} + +func TestEnsureWritableFile(t *testing.T) { + t.Run("allows missing target", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.txt") + if err := EnsureWritableFile(path, false); err != nil { + t.Fatalf("EnsureWritableFile() unexpected error: %v", err) + } + }) + + t.Run("rejects existing target without overwrite", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "exists.txt") + if err := os.WriteFile(path, []byte("data"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + if err := EnsureWritableFile(path, false); err == nil { + t.Fatalf("expected overwrite protection error, got nil") + } + }) + + t.Run("allows existing target with overwrite", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "exists.txt") + if err := os.WriteFile(path, []byte("data"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + if err := EnsureWritableFile(path, true); err != nil { + t.Fatalf("EnsureWritableFile() unexpected error: %v", err) + } + }) +} diff --git a/shortcuts/common/dryrun.go b/shortcuts/common/dryrun.go new file mode 100644 index 00000000..5b90ee29 --- /dev/null +++ b/shortcuts/common/dryrun.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "github.com/larksuite/cli/internal/cmdutil" + +// Type aliases so all existing shortcut code continues to use common.DryRunAPI +// without any changes. The real implementation lives in internal/cmdutil. +type DryRunAPI = cmdutil.DryRunAPI +type DryRunAPICall = cmdutil.DryRunAPICall + +var NewDryRunAPI = cmdutil.NewDryRunAPI diff --git a/shortcuts/common/extract.go b/shortcuts/common/extract.go new file mode 100644 index 00000000..382977ed --- /dev/null +++ b/shortcuts/common/extract.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "github.com/larksuite/cli/internal/util" + +// GetString safely extracts a string from a nested map path. +// Usage: GetString(data, "user", "name") is equivalent to +// data["user"].(map[string]interface{})["name"].(string) +func GetString(m map[string]interface{}, keys ...string) string { + if len(keys) == 0 { + return "" + } + v := navigate(m, keys[:len(keys)-1]) + if v == nil { + return "" + } + s, _ := v[keys[len(keys)-1]].(string) + return s +} + +// GetFloat safely extracts a float64 (the default JSON number type). +func GetFloat(m map[string]interface{}, keys ...string) float64 { + if len(keys) == 0 { + return 0 + } + v := navigate(m, keys[:len(keys)-1]) + if v == nil { + return 0 + } + f, _ := util.ToFloat64(v[keys[len(keys)-1]]) + return f +} + +// GetBool safely extracts a bool. +func GetBool(m map[string]interface{}, keys ...string) bool { + if len(keys) == 0 { + return false + } + v := navigate(m, keys[:len(keys)-1]) + if v == nil { + return false + } + b, _ := v[keys[len(keys)-1]].(bool) + return b +} + +// GetMap safely extracts a nested map. +func GetMap(m map[string]interface{}, keys ...string) map[string]interface{} { + if len(keys) == 0 { + return m + } + return navigate(m, keys) +} + +// GetSlice safely extracts a []interface{}. +func GetSlice(m map[string]interface{}, keys ...string) []interface{} { + if len(keys) == 0 { + return nil + } + v := navigate(m, keys[:len(keys)-1]) + if v == nil { + return nil + } + s, _ := v[keys[len(keys)-1]].([]interface{}) + return s +} + +// EachMap iterates over map elements in a slice, skipping non-map items. +func EachMap(items []interface{}, fn func(m map[string]interface{})) { + if fn == nil { + return + } + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + fn(m) + } + } +} + +// navigate walks a map along the given keys, returning nil if any step fails. +func navigate(m map[string]interface{}, keys []string) map[string]interface{} { + cur := m + for _, k := range keys { + next, ok := cur[k].(map[string]interface{}) + if !ok { + return nil + } + cur = next + } + return cur +} diff --git a/shortcuts/common/extract_test.go b/shortcuts/common/extract_test.go new file mode 100644 index 00000000..373bfc90 --- /dev/null +++ b/shortcuts/common/extract_test.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "testing" +) + +func TestGetString(t *testing.T) { + m := map[string]interface{}{ + "name": "Alice", + "user": map[string]interface{}{ + "id": "u123", + "name": "Bob", + "profile": map[string]interface{}{ + "email": "bob@example.com", + }, + }, + } + + tests := []struct { + name string + keys []string + want string + }{ + {"top level", []string{"name"}, "Alice"}, + {"nested one level", []string{"user", "id"}, "u123"}, + {"nested two levels", []string{"user", "profile", "email"}, "bob@example.com"}, + {"missing key", []string{"missing"}, ""}, + {"missing nested", []string{"user", "missing"}, ""}, + {"wrong type", []string{"user"}, ""}, + {"empty keys", []string{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetString(m, tt.keys...) + if got != tt.want { + t.Errorf("GetString() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetFloat(t *testing.T) { + m := map[string]interface{}{ + "count": 42.0, + "data": map[string]interface{}{ + "score": 99.5, + }, + } + + if got := GetFloat(m, "count"); got != 42.0 { + t.Errorf("GetFloat(count) = %f, want 42.0", got) + } + if got := GetFloat(m, "data", "score"); got != 99.5 { + t.Errorf("GetFloat(data.score) = %f, want 99.5", got) + } + if got := GetFloat(m, "missing"); got != 0 { + t.Errorf("GetFloat(missing) = %f, want 0", got) + } + if got := GetFloat(m); got != 0 { + t.Errorf("GetFloat() = %f, want 0", got) + } +} + +func TestGetBool(t *testing.T) { + m := map[string]interface{}{ + "active": true, + "data": map[string]interface{}{ + "verified": false, + }, + } + + if got := GetBool(m, "active"); got != true { + t.Errorf("GetBool(active) = %v, want true", got) + } + if got := GetBool(m, "data", "verified"); got != false { + t.Errorf("GetBool(data.verified) = %v, want false", got) + } + if got := GetBool(m, "missing"); got != false { + t.Errorf("GetBool(missing) = %v, want false", got) + } + if got := GetBool(m); got != false { + t.Errorf("GetBool() = %v, want false", got) + } +} + +func TestGetMap(t *testing.T) { + inner := map[string]interface{}{"key": "val"} + m := map[string]interface{}{ + "data": inner, + } + + got := GetMap(m, "data") + if got == nil || got["key"] != "val" { + t.Errorf("GetMap(data) = %v, want %v", got, inner) + } + if got := GetMap(m, "missing"); got != nil { + t.Errorf("GetMap(missing) = %v, want nil", got) + } + // No keys returns the original map. + if got := GetMap(m); got == nil { + t.Errorf("GetMap() = nil, want original map") + } +} + +func TestGetSlice(t *testing.T) { + items := []interface{}{"a", "b"} + m := map[string]interface{}{ + "items": items, + "data": map[string]interface{}{ + "list": []interface{}{1.0, 2.0}, + }, + } + + got := GetSlice(m, "items") + if len(got) != 2 { + t.Errorf("GetSlice(items) len = %d, want 2", len(got)) + } + got = GetSlice(m, "data", "list") + if len(got) != 2 { + t.Errorf("GetSlice(data.list) len = %d, want 2", len(got)) + } + if got := GetSlice(m, "missing"); got != nil { + t.Errorf("GetSlice(missing) = %v, want nil", got) + } + if got := GetSlice(m); got != nil { + t.Errorf("GetSlice() = %v, want nil", got) + } +} + +func TestEachMap(t *testing.T) { + items := []interface{}{ + map[string]interface{}{"id": "1"}, + "not a map", + map[string]interface{}{"id": "2"}, + 42, + } + + var ids []string + EachMap(items, func(m map[string]interface{}) { + ids = append(ids, m["id"].(string)) + }) + + if len(ids) != 2 || ids[0] != "1" || ids[1] != "2" { + t.Errorf("EachMap collected ids = %v, want [1 2]", ids) + } +} + +func TestNavigateNilMap(t *testing.T) { + var m map[string]interface{} + if got := GetString(m, "key"); got != "" { + t.Errorf("GetString(nil, key) = %q, want empty", got) + } +} diff --git a/shortcuts/common/helpers.go b/shortcuts/common/helpers.go new file mode 100644 index 00000000..0a5b8e7a --- /dev/null +++ b/shortcuts/common/helpers.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/textproto" + "os" + + "github.com/larksuite/cli/internal/output" +) + +// MultipartWriter wraps multipart.Writer for file uploads. +type MultipartWriter struct { + *multipart.Writer +} + +// NewMultipartWriter creates a new MultipartWriter. +func NewMultipartWriter(w io.Writer) *MultipartWriter { + return &MultipartWriter{multipart.NewWriter(w)} +} + +// CreateFormFile creates a form file with the given field name and file name. +func (mw *MultipartWriter) CreateFormFile(fieldname, filename string) (io.Writer, error) { + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="`+fieldname+`"; filename="`+filename+`"`) + h.Set("Content-Type", "application/octet-stream") + return mw.Writer.CreatePart(h) +} + +// ParseJSON unmarshals JSON data into v. +func ParseJSON(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} + +// EnsureWritableFile refuses to overwrite an existing file unless overwrite is true. +func EnsureWritableFile(path string, overwrite bool) error { + if overwrite { + return nil + } + if _, err := os.Stat(path); err == nil { + return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", path) + } else if !errors.Is(err, os.ErrNotExist) { + return output.Errorf(output.ExitInternal, "io", "cannot access output path %s: %v", path, err) + } + return nil +} diff --git a/shortcuts/common/mcp_client.go b/shortcuts/common/mcp_client.go new file mode 100644 index 00000000..5e87eb66 --- /dev/null +++ b/shortcuts/common/mcp_client.go @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" +) + +const mcpErrorBodyLimit = 4000 + +func MCPEndpoint(brand core.LarkBrand) string { + return core.ResolveEndpoints(brand).MCP + "/mcp" +} + +// CallMCPTool calls an MCP tool via JSON-RPC 2.0 and returns the parsed result. +func CallMCPTool(runtime *RuntimeContext, toolName string, args map[string]interface{}) (map[string]interface{}, error) { + accessToken, err := runtime.AccessToken() + if err != nil { + return nil, err + } + + httpClient, err := runtime.Factory.HttpClient() + if err != nil { + return nil, output.ErrNetwork("failed to get HTTP client: %v", err) + } + + raw, err := DoMCPCall(runtime.Ctx(), httpClient, toolName, args, accessToken, MCPEndpoint(runtime.Config.Brand), runtime.IsBot()) + if err != nil { + return nil, err + } + + return normalizeMCPToolResult(raw) +} + +func normalizeMCPToolResult(raw interface{}) (map[string]interface{}, error) { + result := ExtractMCPResult(raw) + if m, ok := result.(map[string]interface{}); ok { + if errMsg, ok := m["error"].(string); ok && strings.TrimSpace(errMsg) != "" { + return nil, output.Errorf(output.ExitAPI, "mcp_error", "MCP: %s", errMsg) + } + return m, nil + } + if s, ok := result.(string); ok { + return map[string]interface{}{"message": s}, nil + } + return map[string]interface{}{"result": result}, nil +} + +func DoMCPCall(ctx context.Context, httpClient *http.Client, toolName string, args map[string]interface{}, accessToken string, mcpEndpoint string, isBot bool) (interface{}, error) { + body := map[string]interface{}{ + "jsonrpc": "2.0", + "id": uuid.NewString(), + "method": "tools/call", + "params": map[string]interface{}{ + "name": toolName, + "arguments": args, + }, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, output.Errorf(output.ExitInternal, "internal_error", "failed to marshal MCP request body: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, mcpEndpoint, bytes.NewReader(jsonBody)) + if err != nil { + return nil, output.Errorf(output.ExitInternal, "internal_error", "failed to create MCP request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if isBot { + req.Header.Set("X-Lark-MCP-TAT", accessToken) + } else { + req.Header.Set("X-Lark-MCP-UAT", accessToken) + } + req.Header.Set("X-Lark-MCP-Allowed-Tools", toolName) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, output.ErrNetwork("MCP transport failed: %v", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, output.ErrNetwork("failed to read MCP response: %v", err) + } + if resp.StatusCode >= 400 { + return nil, classifyMCPHTTPError(resp.StatusCode, resp.Status, respBody) + } + + var data map[string]interface{} + if err := json.Unmarshal(respBody, &data); err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "MCP returned non-JSON: %s", TruncateStr(string(respBody), mcpErrorBodyLimit)) + } + + if errObj, ok := data["error"]; ok { + return nil, classifyMCPPayloadError(errObj) + } + + return UnwrapMCPResult(data["result"]), nil +} + +func classifyMCPHTTPError(statusCode int, status string, body []byte) error { + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err == nil { + if errObj, ok := payload["error"]; ok { + return classifyMCPPayloadError(errObj) + } + if code, msg, detail, ok := extractMCPBusinessError(payload); ok { + return output.ErrAPI(code, fmt.Sprintf("MCP HTTP %d %s: [%d] %s", statusCode, status, code, msg), detail) + } + } + + bodyText := TruncateStr(strings.TrimSpace(string(body)), mcpErrorBodyLimit) + if statusCode == http.StatusUnauthorized { + return output.ErrAuth("MCP HTTP %d %s: %s", statusCode, status, bodyText) + } + return output.Errorf(output.ExitAPI, "api_error", "MCP HTTP %d %s: %s", statusCode, status, bodyText) +} + +func classifyMCPPayloadError(errObj interface{}) error { + if errMap, ok := errObj.(map[string]interface{}); ok { + msg := GetString(errMap, "message") + if msg == "" { + msg = GetString(errMap, "msg") + } + if code, ok := util.ToFloat64(errMap["code"]); ok { + return output.ErrAPI(int(code), fmt.Sprintf("MCP: [%.0f] %s", code, msg), errMap) + } + if msg != "" { + return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg), errMap) + } + } + + if msg, ok := errObj.(string); ok && strings.TrimSpace(msg) != "" { + return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg), errObj) + } + + return output.Errorf(output.ExitAPI, "api_error", "MCP returned an error response") +} + +func classifyMCPMessageError(msg string, detail interface{}) error { + lower := strings.ToLower(msg) + switch { + case strings.Contains(lower, "unauthorized"), + strings.Contains(lower, "access token"), + strings.Contains(lower, "token invalid"), + strings.Contains(lower, "token expired"): + return &output.ExitError{ + Code: output.ExitAuth, + Detail: &output.ErrDetail{ + Type: "auth", + Message: msg, + Hint: "run `lark-cli auth login` in the background to re-authorize. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", + Detail: detail, + }, + } + default: + code, errType, hint := output.ClassifyLarkError(0, msg) + return &output.ExitError{ + Code: code, + Detail: &output.ErrDetail{ + Type: errType, + Message: msg, + Hint: hint, + Detail: detail, + }, + } + } +} + +func extractMCPBusinessError(payload map[string]interface{}) (int, string, interface{}, bool) { + code, ok := util.ToFloat64(payload["code"]) + if !ok || code == 0 { + return 0, "", nil, false + } + + msg := GetString(payload, "msg") + if msg == "" { + msg = GetString(payload, "message") + } + if msg == "" { + msg = "unknown MCP error" + } + return int(code), msg, payload["error"], true +} + +func UnwrapMCPResult(v interface{}) interface{} { + m, ok := v.(map[string]interface{}) + if !ok { + return v + } + _, hasJSONRPC := m["jsonrpc"] + _, hasResult := m["result"] + _, hasError := m["error"] + + if hasJSONRPC && (hasResult || hasError) { + if hasError { + return v + } + return UnwrapMCPResult(m["result"]) + } + if !hasJSONRPC && hasResult && !hasError { + return UnwrapMCPResult(m["result"]) + } + return v +} + +func ExtractMCPResult(raw interface{}) interface{} { + m, ok := raw.(map[string]interface{}) + if !ok { + return raw + } + + content, ok := m["content"].([]interface{}) + if !ok { + return raw + } + if len(content) == 1 { + if item, ok := content[0].(map[string]interface{}); ok && item["type"] == "text" { + text, _ := item["text"].(string) + var parsed interface{} + if err := json.Unmarshal([]byte(text), &parsed); err == nil { + return parsed + } + return text + } + } + + texts := make([]string, 0, len(content)) + for _, item := range content { + textItem, ok := item.(map[string]interface{}) + if !ok { + continue + } + if text, ok := textItem["text"].(string); ok { + texts = append(texts, text) + } + } + return strings.Join(texts, "\n") +} diff --git a/shortcuts/common/mcp_client_test.go b/shortcuts/common/mcp_client_test.go new file mode 100644 index 00000000..2550652b --- /dev/null +++ b/shortcuts/common/mcp_client_test.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/output" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestDoMCPCallTransportError(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: timeout") + }), + } + + _, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %v", err) + } + if exitErr.Code != output.ExitNetwork { + t.Fatalf("expected network exit code, got %d", exitErr.Code) + } +} + +func TestDoMCPCallUnauthorizedHTTPError(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Status: "401 Unauthorized", + Body: io.NopCloser(strings.NewReader("unauthorized")), + }, nil + }), + } + + _, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %v", err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("expected auth exit code, got %d", exitErr.Code) + } +} + +func TestDoMCPCallJSONRPCErrorUsesLarkClassification(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`{"error":{"code":99991668,"message":"user_access_token invalid"}}`)), + }, nil + }), + } + + _, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %v", err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("expected auth exit code, got %d", exitErr.Code) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "auth" { + t.Fatalf("expected auth detail, got %#v", exitErr.Detail) + } +} + +func TestDoMCPCallSetsHeadersAndUnwrapsResult(t *testing.T) { + t.Parallel() + + var seen *http.Request + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + seen = req + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`{"result":{"jsonrpc":"2.0","result":{"ok":true}}}`)), + }, nil + }), + } + + got, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "tat-token", "https://example.com/mcp", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result, ok := got.(map[string]interface{}) + if !ok || result["ok"] != true { + t.Fatalf("unexpected result: %#v", got) + } + if seen == nil { + t.Fatalf("expected request to be captured") + } + if seen.Header.Get("X-Lark-MCP-TAT") != "tat-token" { + t.Fatalf("expected bot token header, got %q", seen.Header.Get("X-Lark-MCP-TAT")) + } + if seen.Header.Get("X-Lark-MCP-Allowed-Tools") != "fetch-doc" { + t.Fatalf("expected allowed tools header, got %q", seen.Header.Get("X-Lark-MCP-Allowed-Tools")) + } +} + +func TestNormalizeMCPToolResult(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw interface{} + wantKey string + wantVal interface{} + wantErr string + }{ + { + name: "map result", + raw: map[string]interface{}{"ok": true}, + wantKey: "ok", + wantVal: true, + }, + { + name: "text result", + raw: "plain text", + wantKey: "message", + wantVal: "plain text", + }, + { + name: "scalar result", + raw: 42, + wantKey: "result", + wantVal: 42, + }, + { + name: "map error field", + raw: map[string]interface{}{"error": "permission denied"}, + wantErr: "MCP: permission denied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := normalizeMCPToolResult(tt.raw) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got[tt.wantKey] != tt.wantVal { + t.Fatalf("unexpected result: %#v", got) + } + }) + } +} + +func TestExtractMCPResult(t *testing.T) { + t.Parallel() + + jsonResult := ExtractMCPResult(map[string]interface{}{ + "content": []interface{}{ + map[string]interface{}{ + "type": "text", + "text": `{"doc_id":"doc_1"}`, + }, + }, + }) + resultMap, ok := jsonResult.(map[string]interface{}) + if !ok || resultMap["doc_id"] != "doc_1" { + t.Fatalf("unexpected parsed json result: %#v", jsonResult) + } + + textResult := ExtractMCPResult(map[string]interface{}{ + "content": []interface{}{ + map[string]interface{}{"type": "text", "text": "line1"}, + map[string]interface{}{"type": "text", "text": "line2"}, + }, + }) + if textResult != "line1\nline2" { + t.Fatalf("unexpected text result: %#v", textResult) + } +} diff --git a/shortcuts/common/pagination.go b/shortcuts/common/pagination.go new file mode 100644 index 00000000..40ffc9a1 --- /dev/null +++ b/shortcuts/common/pagination.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "fmt" + +// PaginationMeta extracts pagination metadata from an API response data map. +func PaginationMeta(data map[string]interface{}) (hasMore bool, pageToken string) { + hasMore, _ = data["has_more"].(bool) + pageToken, _ = data["page_token"].(string) + if pageToken == "" { + pageToken, _ = data["next_page_token"].(string) + } + return +} + +// PaginationHint returns a human-readable pagination hint for pretty output. +func PaginationHint(data map[string]interface{}, count int) string { + hasMore, token := PaginationMeta(data) + if !hasMore { + return fmt.Sprintf("\n%d total\n", count) + } + return fmt.Sprintf("\n%d total (more available, page_token: %s)\n", count, token) +} diff --git a/shortcuts/common/pagination_test.go b/shortcuts/common/pagination_test.go new file mode 100644 index 00000000..429451db --- /dev/null +++ b/shortcuts/common/pagination_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "strings" + "testing" +) + +func TestPaginationMeta(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + wantMore bool + wantToken string + }{ + { + name: "has more with page_token", + data: map[string]interface{}{"has_more": true, "page_token": "abc"}, + wantMore: true, + wantToken: "abc", + }, + { + name: "has more with next_page_token", + data: map[string]interface{}{"has_more": true, "next_page_token": "def"}, + wantMore: true, + wantToken: "def", + }, + { + name: "page_token preferred over next_page_token", + data: map[string]interface{}{"has_more": true, "page_token": "abc", "next_page_token": "def"}, + wantMore: true, + wantToken: "abc", + }, + { + name: "no more", + data: map[string]interface{}{"has_more": false}, + wantMore: false, + wantToken: "", + }, + { + name: "empty data", + data: map[string]interface{}{}, + wantMore: false, + wantToken: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hasMore, token := PaginationMeta(tt.data) + if hasMore != tt.wantMore { + t.Errorf("hasMore = %v, want %v", hasMore, tt.wantMore) + } + if token != tt.wantToken { + t.Errorf("token = %q, want %q", token, tt.wantToken) + } + }) + } +} + +func TestPaginationHint(t *testing.T) { + t.Run("no more", func(t *testing.T) { + data := map[string]interface{}{"has_more": false} + hint := PaginationHint(data, 5) + if !strings.Contains(hint, "5 total") { + t.Errorf("hint = %q, want to contain '5 total'", hint) + } + if strings.Contains(hint, "more available") { + t.Errorf("hint should not contain 'more available'") + } + }) + + t.Run("has more", func(t *testing.T) { + data := map[string]interface{}{"has_more": true, "page_token": "tok123"} + hint := PaginationHint(data, 10) + if !strings.Contains(hint, "10 total") { + t.Errorf("hint = %q, want to contain '10 total'", hint) + } + if !strings.Contains(hint, "more available") { + t.Errorf("hint = %q, want to contain 'more available'", hint) + } + if !strings.Contains(hint, "tok123") { + t.Errorf("hint = %q, want to contain page token", hint) + } + }) +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go new file mode 100644 index 00000000..1f1df8bc --- /dev/null +++ b/shortcuts/common/runner.go @@ -0,0 +1,697 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// RuntimeContext provides helpers for shortcut execution. +type RuntimeContext struct { + ctx context.Context // from cmd.Context(), propagated through the call chain + Config *core.CliConfig + Cmd *cobra.Command + Format string + botOnly bool // set by framework for bot-only shortcuts + resolvedAs core.Identity // effective identity resolved by framework + Factory *cmdutil.Factory // injected by framework + apiClient *client.APIClient // lazily initialized, cached + larkSDK *lark.Client // eagerly initialized in mountDeclarative +} + +// ── Identity ── + +// As returns the current identity. +// For bot-only shortcuts, always returns AsBot. +// For dual-auth shortcuts, uses the resolved identity (respects default-as config). +func (ctx *RuntimeContext) As() core.Identity { + if ctx.botOnly { + return core.AsBot + } + if ctx.resolvedAs.IsBot() { + return core.AsBot + } + if ctx.resolvedAs != "" { + return ctx.resolvedAs + } + return core.AsUser +} + +// IsBot returns true if current identity is bot. +func (ctx *RuntimeContext) IsBot() bool { + return ctx.As().IsBot() +} + +// UserOpenId returns the current user's open_id from config. +func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId } + +// Ctx returns the context.Context propagated from cmd.Context(). +func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx } + +// getAPIClient returns the cached APIClient, creating it on first use. +func (ctx *RuntimeContext) getAPIClient() (*client.APIClient, error) { + if ctx.apiClient != nil { + return ctx.apiClient, nil + } + ac, err := ctx.Factory.NewAPIClient() + if err != nil { + return nil, err + } + // Override config with the one resolved for this context (may differ from Factory's) + ac.Config = ctx.Config + ctx.apiClient = ac + return ac, nil +} + +// AccessToken returns a valid access token for the current identity. +// For user: returns user access token (with auto-refresh). +// For bot: returns tenant access token. +func (ctx *RuntimeContext) AccessToken() (string, error) { + if ctx.IsBot() { + ac, err := ctx.getAPIClient() + if err != nil { + return "", output.ErrAuth("failed to get SDK: %s", err) + } + tatResp, err := ac.SDK.GetTenantAccessTokenBySelfBuiltApp(ctx.ctx, &larkcore.SelfBuiltTenantAccessTokenReq{ + AppID: ctx.Config.AppID, + AppSecret: ctx.Config.AppSecret, + }) + if err != nil { + return "", output.ErrAuth("failed to get tenant access token: %s", err) + } + return tatResp.TenantAccessToken, nil + } + httpClient, err := ctx.Factory.HttpClient() + if err != nil { + return "", output.ErrAuth("failed to get HTTP client: %s", err) + } + token, err := auth.GetValidAccessToken(httpClient, auth.NewUATCallOptions(ctx.Config, ctx.IO().ErrOut)) + if err != nil { + return "", output.ErrAuth("failed to get access token: %s", err) + } + return token, nil +} + +// LarkSDK returns the eagerly-initialized Lark SDK client. +func (ctx *RuntimeContext) LarkSDK() *lark.Client { + return ctx.larkSDK +} + +// ── Flag accessors ── + +// Str returns a string flag value. +func (ctx *RuntimeContext) Str(name string) string { + v, _ := ctx.Cmd.Flags().GetString(name) + return v +} + +// Bool returns a bool flag value. +func (ctx *RuntimeContext) Bool(name string) bool { + v, _ := ctx.Cmd.Flags().GetBool(name) + return v +} + +// Int returns an int flag value. +func (ctx *RuntimeContext) Int(name string) int { + v, _ := ctx.Cmd.Flags().GetInt(name) + return v +} + +// StrArray returns a string-array flag value (repeated flag, no CSV splitting). +func (ctx *RuntimeContext) StrArray(name string) []string { + v, _ := ctx.Cmd.Flags().GetStringArray(name) + return v +} + +// ── API helpers ── + +// CallAPI uses an internal HTTP wrapper with limited control over request/response. +// +// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options. +// +// CallAPI calls the Lark API using the current identity (ctx.As()) and auto-handles errors. +func (ctx *RuntimeContext) CallAPI(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + result, err := ctx.callRaw(method, url, params, data) + return HandleApiResult(result, err, "API call failed") +} + +// Deprecated: RawAPI uses an internal HTTP wrapper with limited control over request/response. +// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options. +// +// RawAPI calls the Lark API using the current identity (ctx.As()) and returns raw result for manual error handling. +func (ctx *RuntimeContext) RawAPI(method, url string, params map[string]interface{}, data interface{}) (interface{}, error) { + return ctx.callRaw(method, url, params, data) +} + +// PaginateAll fetches all pages and returns a single merged result. +func (ctx *RuntimeContext) PaginateAll(method, url string, params map[string]interface{}, data interface{}, opts client.PaginationOptions) (interface{}, error) { + ac, err := ctx.getAPIClient() + if err != nil { + return nil, err + } + req := ctx.buildRequest(method, url, params, data) + return ac.PaginateAll(ctx.ctx, req, opts) +} + +// StreamPages fetches all pages and streams each page's items via onItems. +// Returns the last result (for error checking) and whether any list items were found. +func (ctx *RuntimeContext) StreamPages(method, url string, params map[string]interface{}, data interface{}, onItems func([]interface{}), opts client.PaginationOptions) (interface{}, bool, error) { + ac, err := ctx.getAPIClient() + if err != nil { + return nil, false, err + } + req := ctx.buildRequest(method, url, params, data) + return ac.StreamPages(ctx.ctx, req, onItems, opts) +} + +func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]interface{}, data interface{}) client.RawApiRequest { + req := client.RawApiRequest{ + Method: method, + URL: url, + Params: params, + Data: data, + As: ctx.As(), + } + if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil { + req.ExtraOpts = append(req.ExtraOpts, optFn) + } + return req +} + +func (ctx *RuntimeContext) callRaw(method, url string, params map[string]interface{}, data interface{}) (interface{}, error) { + ac, err := ctx.getAPIClient() + if err != nil { + return nil, err + } + return ac.CallAPI(ctx.ctx, ctx.buildRequest(method, url, params, data)) +} + +// DoAPI executes a raw Lark SDK request with automatic auth handling. +// Unlike CallAPI which parses JSON and extracts the "data" field, DoAPI returns +// the raw *larkcore.ApiResp — suitable for file downloads (WithFileDownload) +// and uploads (WithFileUpload). +// +// Auth resolution is delegated to APIClient.DoSDKRequest to avoid duplicating +// the identity → token logic across the generic and shortcut API paths. +func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { + ac, err := ctx.getAPIClient() + if err != nil { + return nil, err + } + if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil { + opts = append(opts, optFn) + } + return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...) +} + +type cancelOnCloseReadCloser struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (r *cancelOnCloseReadCloser) Close() error { + err := r.ReadCloser.Close() + if r.cancel != nil { + r.cancel() + } + return err +} + +// DoAPIStream executes a streaming HTTP request against the Lark OpenAPI endpoint +// while preserving the framework's auth resolution, shortcut headers, and security headers. +func (ctx *RuntimeContext) DoAPIStream(callCtx context.Context, req *larkcore.ApiReq, timeout time.Duration, opts ...larkcore.RequestOptionFunc) (*http.Response, error) { + httpClient, err := ctx.Factory.HttpClient() + if err != nil { + return nil, output.ErrNetwork("stream request failed: %s", err) + } + + streamingClient := *httpClient + if timeout > 0 { + streamingClient.Timeout = timeout + } + + requestCtx := callCtx + cancel := func() {} + if timeout > 0 { + if _, hasDeadline := callCtx.Deadline(); !hasDeadline { + requestCtx, cancel = context.WithTimeout(callCtx, timeout) + } + } + + var option larkcore.RequestOption + for _, opt := range opts { + opt(&option) + } + if option.Header == nil { + option.Header = make(http.Header) + } + if shortcutHeaders := cmdutil.ShortcutHeaderOpts(ctx.ctx); shortcutHeaders != nil { + shortcutHeaders(&option) + } + + accessToken, err := ctx.AccessToken() + if err != nil { + cancel() + return nil, err + } + + requestURL, err := buildStreamRequestURL(ctx.Config.Brand, req) + if err != nil { + cancel() + return nil, err + } + bodyReader, contentType, err := buildStreamRequestBody(req.Body) + if err != nil { + cancel() + return nil, err + } + + httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader) + if err != nil { + cancel() + return nil, output.ErrNetwork("stream request failed: %s", err) + } + for key, values := range cmdutil.BaseSecurityHeaders() { + for _, value := range values { + httpReq.Header.Add(key, value) + } + } + for key, values := range option.Header { + for _, value := range values { + httpReq.Header.Add(key, value) + } + } + if contentType != "" { + httpReq.Header.Set("Content-Type", contentType) + } + httpReq.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := streamingClient.Do(httpReq) + if err != nil { + cancel() + return nil, output.ErrNetwork("stream request failed: %s", err) + } + resp.Body = &cancelOnCloseReadCloser{ReadCloser: resp.Body, cancel: cancel} + return resp, nil +} + +func buildStreamRequestURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) { + requestURL := req.ApiPath + if !strings.HasPrefix(requestURL, "http://") && !strings.HasPrefix(requestURL, "https://") { + var pathSegs []string + for _, segment := range strings.Split(req.ApiPath, "/") { + if !strings.HasPrefix(segment, ":") { + pathSegs = append(pathSegs, segment) + continue + } + pathKey := strings.TrimPrefix(segment, ":") + pathValue, ok := req.PathParams[pathKey] + if !ok { + return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath) + } + if pathValue == "" { + return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath) + } + pathSegs = append(pathSegs, url.PathEscape(pathValue)) + } + endpoints := core.ResolveEndpoints(brand) + requestURL = strings.TrimRight(endpoints.Open, "/") + strings.Join(pathSegs, "/") + } + if query := req.QueryParams.Encode(); query != "" { + requestURL += "?" + query + } + return requestURL, nil +} + +func buildStreamRequestBody(body interface{}) (io.Reader, string, error) { + switch typed := body.(type) { + case nil: + return nil, "", nil + case io.Reader: + return typed, "", nil + case []byte: + return bytes.NewReader(typed), "", nil + case string: + return strings.NewReader(typed), "text/plain; charset=utf-8", nil + default: + payload, err := json.Marshal(typed) + if err != nil { + return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err) + } + return bytes.NewReader(payload), "application/json", nil + } +} + +// DoAPIJSON calls the Lark API via DoAPI, parses the JSON response envelope, +// and returns the "data" field. Suitable for standard JSON APIs (non-file). +func (ctx *RuntimeContext) DoAPIJSON(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) { + req := &larkcore.ApiReq{ + HttpMethod: method, + ApiPath: apiPath, + QueryParams: query, + } + if body != nil { + req.Body = body + } + resp, err := ctx.DoAPI(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + if len(resp.RawBody) > 0 { + var errEnv struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if json.Unmarshal(resp.RawBody, &errEnv) == nil && errEnv.Msg != "" { + return nil, output.ErrAPI(errEnv.Code, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, errEnv.Msg), nil) + } + } + return nil, output.ErrAPI(resp.StatusCode, fmt.Sprintf("HTTP %d", resp.StatusCode), nil) + } + if len(resp.RawBody) == 0 { + return nil, fmt.Errorf("empty response body") + } + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(resp.RawBody, &envelope); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + if envelope.Code != 0 { + return nil, output.ErrAPI(envelope.Code, envelope.Msg, nil) + } + return envelope.Data, nil +} + +// ── IO access ── + +// IO returns the IOStreams from the Factory. +func (ctx *RuntimeContext) IO() *cmdutil.IOStreams { + return ctx.Factory.IOStreams +} + +// ── Output helpers ── + +// Out prints a success JSON envelope to stdout. +func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) { + env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta} + b, _ := json.MarshalIndent(env, "", " ") + fmt.Fprintln(ctx.IO().Out, string(b)) +} + +// OutFormat prints output based on --format flag. +// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue. +func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) { + switch ctx.Format { + case "pretty": + if prettyFn != nil { + prettyFn(ctx.IO().Out) + } else { + ctx.Out(data, meta) + } + case "json", "": + ctx.Out(data, meta) + default: + // table, csv, ndjson — pass data directly; FormatValue handles both + // plain arrays and maps with array fields (e.g. {"members":[…]}) + format, formatOK := output.ParseFormat(ctx.Format) + if !formatOK { + fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format) + } + output.FormatValue(ctx.IO().Out, data, format) + } +} + +// ── Scope pre-check ── + +// checkScopePrereqs performs a fast local check: does the stored token +// contain all scopes declared by the shortcut? Returns the missing ones. +// If no token is stored, returns nil (let the normal auth flow handle it). +func checkScopePrereqs(appID, userOpenId string, required []string) []string { + stored := auth.GetStoredToken(appID, userOpenId) + if stored == nil { + return nil // no token yet — auth flow will catch this later + } + return auth.MissingScopes(stored.Scope, required) +} + +// enhancePermissionError enriches a permission / auth error with the +// shortcut's declared required scopes so the user knows exactly what to do. +func enhancePermissionError(err error, requiredScopes []string) error { + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + return err + } + + // Detect permission-related errors by type or message keywords. + isPermErr := exitErr.Detail.Type == "permission" || exitErr.Detail.Type == "missing_scope" + if !isPermErr { + lower := strings.ToLower(exitErr.Detail.Message) + for _, kw := range []string{"permission", "scope", "authorization", "unauthorized"} { + if strings.Contains(lower, kw) { + isPermErr = true + break + } + } + } + if !isPermErr { + return err + } + + scopeDisplay := strings.Join(requiredScopes, ", ") + scopeArg := strings.Join(requiredScopes, " ") + hint := fmt.Sprintf( + "this command requires scope(s): %s\nrun `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", + scopeDisplay, scopeArg) + // Return a new error instead of mutating the original's Detail in place. + return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) +} + +// ── Mounting ── + +// Mount registers the shortcut on a parent command. +func (s Shortcut) Mount(parent *cobra.Command, f *cmdutil.Factory) { + if s.Execute != nil { + s.mountDeclarative(parent, f) + } +} + +func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) { + shortcut := s + if len(shortcut.AuthTypes) == 0 { + shortcut.AuthTypes = []string{"user"} + } + botOnly := len(shortcut.AuthTypes) == 1 && shortcut.AuthTypes[0] == "bot" + + cmd := &cobra.Command{ + Use: shortcut.Command, + Short: shortcut.Description, + RunE: func(cmd *cobra.Command, _ []string) error { + return runShortcut(cmd, f, &shortcut, botOnly) + }, + } + registerShortcutFlags(cmd, &shortcut) + cmdutil.SetTips(cmd, shortcut.Tips) + parent.AddCommand(cmd) +} + +// runShortcut is the execution pipeline for a declarative shortcut. +// Each step is a clear phase: identity → config → scopes → context → validate → execute. +func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error { + as, err := resolveShortcutIdentity(cmd, f, s) + if err != nil { + return err + } + + config, err := f.ResolveConfig(as) + if err != nil { + return err + } + // Identity info is now included in the JSON envelope; skip stderr printing. + // cmdutil.PrintIdentity(f.IOStreams.ErrOut, as, config, false) + + if err := checkShortcutScopes(as, config, s.ScopesForIdentity(string(as))); err != nil { + return err + } + + rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly) + if err != nil { + return err + } + + if err := validateEnumFlags(rctx, s.Flags); err != nil { + return err + } + if s.Validate != nil { + if err := s.Validate(rctx.ctx, rctx); err != nil { + return err + } + } + + if rctx.Bool("dry-run") { + return handleShortcutDryRun(f, rctx, s) + } + + if s.Risk == "high-risk-write" { + if err := RequireConfirmation(s.Risk, rctx.Bool("yes"), s.Description); err != nil { + return err + } + } + + return s.Execute(rctx.ctx, rctx) +} + +func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) { + // Step 1: determine identity (--as > default-as > auto-detect). + asFlag, _ := cmd.Flags().GetString("as") + as := f.ResolveAs(cmd, core.Identity(asFlag)) + + // Step 2: check if this shortcut supports the resolved identity. + if err := f.CheckIdentity(as, s.AuthTypes); err != nil { + return "", err + } + return as, nil +} + +func checkShortcutScopes(as core.Identity, config *core.CliConfig, scopes []string) error { + if as != core.AsUser || len(scopes) == 0 || config.UserOpenId == "" { + return nil + } + missing := checkScopePrereqs(config.AppID, config.UserOpenId, scopes) + if len(missing) == 0 { + return nil + } + return output.ErrWithHint(output.ExitAuth, "missing_scope", + fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), + fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) +} + +func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) { + ctx := cmd.Context() + ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String()) + rctx := &RuntimeContext{ctx: ctx, Config: config, Cmd: cmd, botOnly: botOnly, resolvedAs: as, Factory: f} + + sdk, err := f.LarkClient() + if err != nil { + return nil, err + } + rctx.larkSDK = sdk + + if s.HasFormat { + rctx.Format = rctx.Str("format") + } + return rctx, nil +} + +func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error { + for _, fl := range flags { + if len(fl.Enum) == 0 { + continue + } + val := rctx.Str(fl.Name) + if val == "" { + continue + } + valid := false + for _, allowed := range fl.Enum { + if val == allowed { + valid = true + break + } + } + if !valid { + return FlagErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")) + } + } + return nil +} + +func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error { + if s.DryRun == nil { + return FlagErrorf("--dry-run is not supported for %s %s", s.Service, s.Command) + } + fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===") + dryResult := s.DryRun(rctx.ctx, rctx) + if rctx.Format == "pretty" { + fmt.Fprint(f.IOStreams.Out, dryResult.Format()) + } else { + output.PrintJson(f.IOStreams.Out, dryResult) + } + return nil +} + +func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) { + for _, fl := range s.Flags { + desc := fl.Desc + if len(fl.Enum) > 0 { + desc += " (" + strings.Join(fl.Enum, "|") + ")" + } + switch fl.Type { + case "bool": + def := fl.Default == "true" + cmd.Flags().Bool(fl.Name, def, desc) + case "int": + var d int + fmt.Sscanf(fl.Default, "%d", &d) + cmd.Flags().Int(fl.Name, d, desc) + case "string_array": + cmd.Flags().StringArray(fl.Name, nil, desc) + default: + cmd.Flags().String(fl.Name, fl.Default, desc) + } + if fl.Hidden { + _ = cmd.Flags().MarkHidden(fl.Name) + } + if fl.Required { + cmd.MarkFlagRequired(fl.Name) + } + if len(fl.Enum) > 0 { + vals := fl.Enum + _ = cmd.RegisterFlagCompletionFunc(fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return vals, cobra.ShellCompDirectiveNoFileComp + }) + } + } + + cmd.Flags().Bool("dry-run", false, "print request without executing") + if s.HasFormat { + cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv") + } + if s.Risk == "high-risk-write" { + cmd.Flags().Bool("yes", false, "confirm high-risk operation") + } + cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot") + + _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp + }) + if s.HasFormat { + _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp + }) + } +} diff --git a/shortcuts/common/runner_scope_test.go b/shortcuts/common/runner_scope_test.go new file mode 100644 index 00000000..e7a4efbd --- /dev/null +++ b/shortcuts/common/runner_scope_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/larksuite/cli/internal/output" +) + +func TestEnhancePermissionError_MissingScopeType(t *testing.T) { + scopes := []string{"calendar:calendar:read"} + err := &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "missing_scope", Message: "missing scope"}, + } + got := enhancePermissionError(err, scopes) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T", got) + } + if exitErr.Detail.Hint == "" { + t.Error("expected hint for missing_scope type") + } + if !strings.Contains(exitErr.Detail.Hint, "calendar:calendar:read") { + t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + } +} + +func TestEnhancePermissionError_KeywordPermission(t *testing.T) { + scopes := []string{"drive:drive:read"} + err := &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "api_error", Message: "Permission denied for resource"}, + } + got := enhancePermissionError(err, scopes) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T", got) + } + if !strings.Contains(exitErr.Detail.Hint, "drive:drive:read") { + t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + } +} + +func TestEnhancePermissionError_KeywordScope(t *testing.T) { + scopes := []string{"task:task:read"} + err := &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "api_error", Message: "Insufficient scope for operation"}, + } + got := enhancePermissionError(err, scopes) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T", got) + } + if !strings.Contains(exitErr.Detail.Hint, "task:task:read") { + t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + } +} + +func TestEnhancePermissionError_KeywordAuthorization(t *testing.T) { + scopes := []string{"contact:contact:read"} + err := &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "api_error", Message: "Authorization required"}, + } + got := enhancePermissionError(err, scopes) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T", got) + } + if !strings.Contains(exitErr.Detail.Hint, "contact:contact:read") { + t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + } +} + +func TestEnhancePermissionError(t *testing.T) { + scopes := []string{"calendar:calendar:read", "drive:drive:read"} + + tests := []struct { + name string + err error + wantHint bool + hintSubstr string + }{ + { + name: "permission type gets enhanced", + err: &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "permission", Message: "no permission"}, + }, + wantHint: true, + hintSubstr: "scope", + }, + { + name: "mcp_error with unauthorized keyword gets enhanced", + err: &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "mcp_error", Message: "request unauthorized by server"}, + }, + wantHint: true, + hintSubstr: "scope", + }, + { + name: "api_error without keyword not modified", + err: &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "api_error", Message: "timeout"}, + }, + wantHint: false, + }, + { + name: "plain error not modified", + err: fmt.Errorf("plain error"), + wantHint: false, + }, + { + name: "nil Detail not modified", + err: &output.ExitError{ + Code: 1, + Detail: nil, + }, + wantHint: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := enhancePermissionError(tt.err, scopes) + + if !tt.wantHint { + // Should return original error unchanged + if got != tt.err { + t.Errorf("expected original error returned, got different error: %v", got) + } + return + } + + // Should return an enhanced ExitError with a hint + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", got, got) + } + if exitErr.Detail == nil { + t.Fatal("expected Detail to be non-nil") + } + if exitErr.Detail.Hint == "" { + t.Fatal("expected non-empty hint") + } + if !strings.Contains(exitErr.Detail.Hint, tt.hintSubstr) { + t.Errorf("hint %q does not contain %q", exitErr.Detail.Hint, tt.hintSubstr) + } + // Verify the hint includes the actual scopes + for _, s := range scopes { + if !strings.Contains(exitErr.Detail.Hint, s) { + t.Errorf("hint %q does not contain scope %q", exitErr.Detail.Hint, s) + } + } + }) + } +} diff --git a/shortcuts/common/sanitize.go b/shortcuts/common/sanitize.go new file mode 100644 index 00000000..14e00e8d --- /dev/null +++ b/shortcuts/common/sanitize.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +// IsDangerousUnicode reports whether r is a Unicode character that can cause +// terminal injection: BiDi overrides, zero-width characters, and Unicode line +// terminators. +func IsDangerousUnicode(r rune) bool { + switch { + case r >= 0x200B && r <= 0x200D: // ZWSP / ZWJ / ZWNJ + return true + case r == 0xFEFF: // BOM / ZWNBSP + return true + case r >= 0x202A && r <= 0x202E: // BiDi: LRE, RLE, PDF, LRO, RLO + return true + case r >= 0x2028 && r <= 0x2029: // LS, PS + return true + case r >= 0x2066 && r <= 0x2069: // LRI, RLI, FSI, PDI + return true + } + return false +} diff --git a/shortcuts/common/testing.go b/shortcuts/common/testing.go new file mode 100644 index 00000000..aca00b00 --- /dev/null +++ b/shortcuts/common/testing.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/core" +) + +// TestNewRuntimeContext creates a RuntimeContext for testing purposes. +// Only Cmd and Config are set; other fields (Factory, larkSDK, etc.) are nil. +func TestNewRuntimeContext(cmd *cobra.Command, cfg *core.CliConfig) *RuntimeContext { + return &RuntimeContext{Cmd: cmd, Config: cfg} +} + +// TestNewRuntimeContextWithCtx creates a RuntimeContext with an explicit context +// for tests that invoke functions which call Ctx() (e.g. HTTP request helpers). +func TestNewRuntimeContextWithCtx(ctx context.Context, cmd *cobra.Command, cfg *core.CliConfig) *RuntimeContext { + return &RuntimeContext{ctx: ctx, Cmd: cmd, Config: cfg} +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go new file mode 100644 index 00000000..70c882cd --- /dev/null +++ b/shortcuts/common/types.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "context" + +// Flag describes a CLI flag for a shortcut. +type Flag struct { + Name string // flag name (e.g. "calendar-id") + Type string // "string" (default) | "bool" | "int" | "string_array" + Default string // default value as string + Desc string // help text + Hidden bool // hidden from --help, still readable at runtime + Required bool + Enum []string // allowed values (e.g. ["asc", "desc"]); empty means no constraint +} + +// Shortcut represents a high-level CLI command. +type Shortcut struct { + Service string + Command string + Description string + Risk string // "read" | "write" | "high-risk-write" (empty defaults to "read") + Scopes []string // default scopes (fallback when UserScopes/BotScopes are empty) + UserScopes []string // optional: user-identity scopes (overrides Scopes when non-empty) + BotScopes []string // optional: bot-identity scopes (overrides Scopes when non-empty) + + // Declarative fields (new framework). + AuthTypes []string // supported identities: "user", "bot" (default: ["user"]) + Flags []Flag // flag definitions; --dry-run is auto-injected + HasFormat bool // auto-inject --format flag (json|pretty|table|ndjson|csv) + Tips []string // optional tips shown in --help output + + // Business logic hooks. + DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set + Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation + Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic +} + +// ScopesForIdentity returns the scopes applicable for the given identity. +// If identity-specific scopes (UserScopes/BotScopes) are set, they take +// precedence over the default Scopes. +func (s *Shortcut) ScopesForIdentity(identity string) []string { + switch identity { + case "user": + if len(s.UserScopes) > 0 { + return s.UserScopes + } + case "bot": + if len(s.BotScopes) > 0 { + return s.BotScopes + } + } + return s.Scopes +} diff --git a/shortcuts/common/types_test.go b/shortcuts/common/types_test.go new file mode 100644 index 00000000..ba067e4c --- /dev/null +++ b/shortcuts/common/types_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "reflect" + "testing" +) + +func TestScopesForIdentity_FallbackToScopes(t *testing.T) { + s := Shortcut{Scopes: []string{"a", "b"}} + for _, id := range []string{"user", "bot", "tenant", ""} { + got := s.ScopesForIdentity(id) + if !reflect.DeepEqual(got, s.Scopes) { + t.Errorf("identity=%q: expected %v, got %v", id, s.Scopes, got) + } + } +} + +func TestScopesForIdentity_UserScopesOverride(t *testing.T) { + s := Shortcut{ + Scopes: []string{"default"}, + UserScopes: []string{"user-only"}, + } + if got := s.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"user-only"}) { + t.Errorf("expected UserScopes, got %v", got) + } + // bot should still fall back + if got := s.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"default"}) { + t.Errorf("expected Scopes fallback for bot, got %v", got) + } +} + +func TestScopesForIdentity_BotScopesOverride(t *testing.T) { + s := Shortcut{ + Scopes: []string{"default"}, + BotScopes: []string{"bot-only"}, + } + if got := s.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"bot-only"}) { + t.Errorf("expected BotScopes, got %v", got) + } + // user should still fall back + if got := s.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"default"}) { + t.Errorf("expected Scopes fallback for user, got %v", got) + } +} + +func TestScopesForIdentity_BothOverrides(t *testing.T) { + s := Shortcut{ + Scopes: []string{"default"}, + UserScopes: []string{"u1", "u2"}, + BotScopes: []string{"b1"}, + } + if got := s.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"u1", "u2"}) { + t.Errorf("expected UserScopes, got %v", got) + } + if got := s.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"b1"}) { + t.Errorf("expected BotScopes, got %v", got) + } + // unknown identity falls back + if got := s.ScopesForIdentity("tenant"); !reflect.DeepEqual(got, []string{"default"}) { + t.Errorf("expected Scopes fallback for tenant, got %v", got) + } +} + +func TestScopesForIdentity_NilScopes(t *testing.T) { + s := Shortcut{} + got := s.ScopesForIdentity("user") + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} diff --git a/shortcuts/common/validate.go b/shortcuts/common/validate.go new file mode 100644 index 00000000..422f99e2 --- /dev/null +++ b/shortcuts/common/validate.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" +) + +// FlagErrorf returns a validation error with flag context (exit code 2). +func FlagErrorf(format string, args ...any) error { + return output.ErrValidation(format, args...) +} + +// MutuallyExclusive checks that at most one of the given flags is set. +func MutuallyExclusive(rt *RuntimeContext, flags ...string) error { + var set []string + for _, f := range flags { + val := rt.Str(f) + if val != "" { + set = append(set, "--"+f) + } + } + if len(set) > 1 { + return FlagErrorf("%s are mutually exclusive", strings.Join(set, " and ")) + } + return nil +} + +// AtLeastOne checks that at least one of the given flags is set. +func AtLeastOne(rt *RuntimeContext, flags ...string) error { + for _, f := range flags { + if rt.Str(f) != "" { + return nil + } + } + names := make([]string, len(flags)) + for i, f := range flags { + names[i] = "--" + f + } + return FlagErrorf("specify at least one of %s", strings.Join(names, " or ")) +} + +// ExactlyOne checks that exactly one of the given flags is set. +func ExactlyOne(rt *RuntimeContext, flags ...string) error { + if err := AtLeastOne(rt, flags...); err != nil { + return err + } + return MutuallyExclusive(rt, flags...) +} + +// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal]. +// It returns the parsed value (or defaultVal if the flag is empty) and any validation error. +func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) { + s := rt.Str(flagName) + if s == "" { + return defaultVal, nil + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, FlagErrorf("invalid --%s %q: must be an integer", flagName, s) + } + if n < minVal || n > maxVal { + return 0, FlagErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal) + } + return n, nil +} + +// ParseIntBounded parses an int flag and clamps it to [min, max]. +func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int { + v := rt.Int(name) + if v < min { + return min + } + if v > max { + return max + } + return v +} + +// ValidateSafeOutputDir ensures outputDir is a relative path that resolves +// within the current working directory, preventing path traversal attacks +// (including symlink-based escape). +func ValidateSafeOutputDir(outputDir string) error { + if filepath.IsAbs(outputDir) { + return fmt.Errorf("--output-dir must be a relative path, got: %q", outputDir) + } + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("cannot determine working directory: %w", err) + } + canonicalCwd, err := filepath.EvalSymlinks(cwd) + if err != nil { + canonicalCwd = cwd + } + abs := filepath.Clean(filepath.Join(cwd, outputDir)) + + // Resolve symlinks in abs to prevent symlink-escape attacks (e.g. an + // attacker-controlled symlink inside CWD pointing outside). + canonicalAbs, err := filepath.EvalSymlinks(abs) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("--output-dir %q: %w", outputDir, err) + } + // Path does not exist yet. If os.Lstat succeeds the entry is a dangling + // symlink — reject it to prevent future escapes once the target is created. + if _, lstErr := os.Lstat(abs); lstErr == nil { + return fmt.Errorf("--output-dir %q is a symlink with a non-existent target", outputDir) + } + // The path itself doesn't exist; the string-level check is sufficient. + canonicalAbs = abs + } + + if !strings.HasPrefix(canonicalAbs, canonicalCwd+string(filepath.Separator)) { + return fmt.Errorf("--output-dir %q resolves outside the working directory", outputDir) + } + return nil +} + +// RejectDangerousChars returns an error if value contains ASCII control +// characters or dangerous Unicode code points. +func RejectDangerousChars(paramName, value string) error { + for _, r := range value { + if r < 0x20 && r != '\t' && r != '\n' { + return fmt.Errorf("parameter %q contains control character U+%04X", paramName, r) + } + if r == 0x7F { + return fmt.Errorf("parameter %q contains DEL character", paramName) + } + if IsDangerousUnicode(r) { + return fmt.Errorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r) + } + } + return nil +} diff --git a/shortcuts/common/validate_ids.go b/shortcuts/common/validate_ids.go new file mode 100644 index 00000000..50697087 --- /dev/null +++ b/shortcuts/common/validate_ids.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "strings" + + "github.com/larksuite/cli/internal/output" +) + +// ValidateChatID checks if a chat ID has valid format (oc_ prefix). +// Also extracts token from URL if provided. +func ValidateChatID(input string) (string, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", output.ErrValidation("chat ID cannot be empty") + } + // Extract from URL if present + if strings.Contains(input, "feishu.cn") || strings.Contains(input, "larksuite.com") { + // Extract oc_xxx from URL + parts := strings.Split(input, "/") + for _, part := range parts { + if strings.HasPrefix(part, "oc_") { + input = part + break + } + } + } + if !strings.HasPrefix(input, "oc_") { + return "", output.ErrValidation("invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)") + } + return input, nil +} + +// ValidateUserID checks if a user ID has valid format (ou_ prefix). +func ValidateUserID(input string) (string, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", output.ErrValidation("user ID cannot be empty") + } + if !strings.HasPrefix(input, "ou_") { + return "", output.ErrValidation("invalid user ID format, should start with 'ou_' (e.g., ou_abc123)") + } + return input, nil +} diff --git a/shortcuts/common/validate_test.go b/shortcuts/common/validate_test.go new file mode 100644 index 00000000..d33d2535 --- /dev/null +++ b/shortcuts/common/validate_test.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +// newTestRuntime creates a RuntimeContext with string flags for testing. +func newTestRuntime(flags map[string]string) *RuntimeContext { + cmd := &cobra.Command{Use: "test"} + for name := range flags { + cmd.Flags().String(name, "", "") + } + // Parse empty args so flags have defaults, then set values. + cmd.ParseFlags(nil) + for name, val := range flags { + cmd.Flags().Set(name, val) + } + return &RuntimeContext{Cmd: cmd} +} + +func TestMutuallyExclusive(t *testing.T) { + tests := []struct { + name string + flags map[string]string + check []string + wantErr bool + }{ + { + name: "none set", + flags: map[string]string{"a": "", "b": ""}, + check: []string{"a", "b"}, + wantErr: false, + }, + { + name: "one set", + flags: map[string]string{"a": "x", "b": ""}, + check: []string{"a", "b"}, + wantErr: false, + }, + { + name: "both set", + flags: map[string]string{"a": "x", "b": "y"}, + check: []string{"a", "b"}, + wantErr: true, + }, + { + name: "three flags two set", + flags: map[string]string{"a": "x", "b": "", "c": "z"}, + check: []string{"a", "b", "c"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := newTestRuntime(tt.flags) + err := MutuallyExclusive(rt, tt.check...) + if (err != nil) != tt.wantErr { + t.Errorf("MutuallyExclusive() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAtLeastOne(t *testing.T) { + tests := []struct { + name string + flags map[string]string + check []string + wantErr bool + }{ + { + name: "none set", + flags: map[string]string{"a": "", "b": ""}, + check: []string{"a", "b"}, + wantErr: true, + }, + { + name: "one set", + flags: map[string]string{"a": "x", "b": ""}, + check: []string{"a", "b"}, + wantErr: false, + }, + { + name: "both set", + flags: map[string]string{"a": "x", "b": "y"}, + check: []string{"a", "b"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := newTestRuntime(tt.flags) + err := AtLeastOne(rt, tt.check...) + if (err != nil) != tt.wantErr { + t.Errorf("AtLeastOne() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestExactlyOne(t *testing.T) { + tests := []struct { + name string + flags map[string]string + check []string + wantErr bool + }{ + { + name: "none set", + flags: map[string]string{"a": "", "b": ""}, + check: []string{"a", "b"}, + wantErr: true, + }, + { + name: "one set", + flags: map[string]string{"a": "x", "b": ""}, + check: []string{"a", "b"}, + wantErr: false, + }, + { + name: "both set", + flags: map[string]string{"a": "x", "b": "y"}, + check: []string{"a", "b"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := newTestRuntime(tt.flags) + err := ExactlyOne(rt, tt.check...) + if (err != nil) != tt.wantErr { + t.Errorf("ExactlyOne() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestParseIntBounded(t *testing.T) { + tests := []struct { + name string + val string + min, max int + want int + }{ + {"within range", "10", 1, 50, 10}, + {"below min", "0", 1, 50, 1}, + {"above max", "100", 1, 50, 50}, + {"at min", "1", 1, 50, 1}, + {"at max", "50", 1, 50, 50}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 0, "") + cmd.ParseFlags(nil) + cmd.Flags().Set("page-size", tt.val) + rt := &RuntimeContext{Cmd: cmd} + got := ParseIntBounded(rt, "page-size", tt.min, tt.max) + if got != tt.want { + t.Errorf("ParseIntBounded() = %d, want %d", got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ValidateSafeOutputDir — symlink escape prevention +// --------------------------------------------------------------------------- + +// chdirForTest changes CWD to dir and restores the original CWD on cleanup. +func chdirForTest(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q): %v", dir, err) + } + t.Cleanup(func() { os.Chdir(orig) }) +} + +// TestValidateSafeOutputDir_RejectsSymlinkEscape verifies that a relative path +// that resolves to a symlink pointing outside CWD is rejected. +func TestValidateSafeOutputDir_RejectsSymlinkEscape(t *testing.T) { + outside := t.TempDir() // target outside CWD + workDir := t.TempDir() + chdirForTest(t, workDir) + + // Create a symlink inside CWD pointing to outside. + if err := os.Symlink(outside, filepath.Join(workDir, "evil_out")); err != nil { + t.Fatalf("Symlink: %v", err) + } + + if err := ValidateSafeOutputDir("evil_out"); err == nil { + t.Fatal("expected error for symlink pointing outside CWD, got nil") + } +} + +// TestValidateSafeOutputDir_RejectsDanglingSymlink verifies that a dangling +// symlink (target does not exist) is rejected to prevent future escapes. +func TestValidateSafeOutputDir_RejectsDanglingSymlink(t *testing.T) { + workDir := t.TempDir() + chdirForTest(t, workDir) + + if err := os.Symlink("/nonexistent/outside/target", filepath.Join(workDir, "dangling")); err != nil { + t.Fatalf("Symlink: %v", err) + } + + if err := ValidateSafeOutputDir("dangling"); err == nil { + t.Fatal("expected error for dangling symlink, got nil") + } +} + +// TestValidateSafeOutputDir_AllowsNormalSubdir verifies that an existing real +// subdirectory within CWD is accepted. +func TestValidateSafeOutputDir_AllowsNormalSubdir(t *testing.T) { + workDir := t.TempDir() + chdirForTest(t, workDir) + + subDir := filepath.Join(workDir, "output") + if err := os.Mkdir(subDir, 0700); err != nil { + t.Fatalf("Mkdir: %v", err) + } + + if err := ValidateSafeOutputDir("output"); err != nil { + t.Fatalf("expected no error for real subdir, got: %v", err) + } +} + +// TestValidateSafeOutputDir_AllowsNonExistentPath verifies that a path that +// does not yet exist (new output directory) is accepted. +func TestValidateSafeOutputDir_AllowsNonExistentPath(t *testing.T) { + workDir := t.TempDir() + chdirForTest(t, workDir) + + if err := ValidateSafeOutputDir("new_output_dir"); err != nil { + t.Fatalf("expected no error for non-existent path, got: %v", err) + } +} diff --git a/shortcuts/contact/contact_get_user.go b/shortcuts/contact/contact_get_user.go new file mode 100644 index 00000000..a056a637 --- /dev/null +++ b/shortcuts/contact/contact_get_user.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "context" + "net/url" + + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var ContactGetUser = common.Shortcut{ + Service: "contact", + Command: "+get-user", + Description: "Get user info (omit user_id for self; provide user_id for specific user)", + Risk: "read", + UserScopes: []string{"contact:user.basic_profile:readonly"}, + BotScopes: []string{"contact:user.base:readonly", "contact:contact.base:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "user-id", Desc: "user ID (omit to get current user)"}, + {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Str("user-id") == "" && runtime.IsBot() { + return common.FlagErrorf("bot identity cannot get current user info, specify --user-id") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + userId := runtime.Str("user-id") + if userId == "" { + return common.NewDryRunAPI(). + GET("/open-apis/authen/v1/user_info"). + Desc("(when --user-id omitted) Get current authenticated user info"). + Set("mode", "current_user") + } + userIdType := runtime.Str("user-id-type") + if userIdType == "" { + userIdType = "open_id" + } + if runtime.IsBot() { + return common.NewDryRunAPI(). + GET("/open-apis/contact/v3/users/:user_id"). + Desc("(bot) Get user info by user ID"). + Params(map[string]interface{}{"user_id_type": userIdType}). + Set("user_id", userId).Set("user_id_type", userIdType) + } + return common.NewDryRunAPI(). + POST("/open-apis/contact/v3/users/basic_batch"). + Desc("(user) Get user basic info by user ID"). + Params(map[string]interface{}{"user_id_type": userIdType}). + Body(map[string]interface{}{"user_ids": []string{userId}}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + userId := runtime.Str("user-id") + userIdType := runtime.Str("user-id-type") + + if userId == "" { + // Current user + data, err := runtime.CallAPI("GET", "/open-apis/authen/v1/user_info", nil, nil) + if err != nil { + return err + } + user := data + if user == nil { + user = make(map[string]interface{}) + } + userData := map[string]interface{}{"user": user} + runtime.OutFormat(userData, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{{ + "name": pickUserName(user), + "open_id": user["open_id"], + "union_id": user["union_id"], + "email": firstNonEmpty(user, "email", "mail"), + "mobile": firstNonEmpty(user, "mobile", "phone"), + "enterprise_email": firstNonEmpty(user, "enterprise_email"), + }}) + }) + return nil + } + + if runtime.IsBot() { + // Bot identity: GET /contact/v3/users/:user_id (full profile) + data, err := runtime.CallAPI("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId), + map[string]interface{}{"user_id_type": userIdType}, nil) + if err != nil { + return err + } + user, _ := data["user"].(map[string]interface{}) + if user == nil { + user = data + } + userData := map[string]interface{}{"user": user} + runtime.OutFormat(userData, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{{ + "name": pickUserName(user), + "open_id": firstNonEmpty(user, "open_id", "user_id"), + "email": firstNonEmpty(user, "email", "enterprise_email"), + "mobile": firstNonEmpty(user, "mobile", "mobile_phone"), + "department": firstNonEmpty(user, "department_name"), + }}) + }) + return nil + } + + // User identity: POST /contact/v3/users/basic_batch (lightweight) + data, err := runtime.CallAPI("POST", "/open-apis/contact/v3/users/basic_batch", + map[string]interface{}{"user_id_type": userIdType}, + map[string]interface{}{"user_ids": []string{userId}}) + if err != nil { + return err + } + users, _ := data["users"].([]interface{}) + var user map[string]interface{} + if len(users) > 0 { + user, _ = users[0].(map[string]interface{}) + } + if user == nil { + user = make(map[string]interface{}) + } + userData := map[string]interface{}{"user": user} + runtime.OutFormat(userData, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{{ + "name": pickUserName(user), + "user_id": user["user_id"], + }}) + }) + return nil + }, +} diff --git a/shortcuts/contact/contact_search_user.go b/shortcuts/contact/contact_search_user.go new file mode 100644 index 00000000..b6b29058 --- /dev/null +++ b/shortcuts/contact/contact_search_user.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "context" + "fmt" + "io" + "math" + "strconv" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var ContactSearchUser = common.Shortcut{ + Service: "contact", + Command: "+search-user", + Description: "Search users (results sorted by relevance)", + Risk: "read", + Scopes: []string{"contact:user:search"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "query", Desc: "search keyword", Required: true}, + {Name: "page-size", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "page token"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if len(runtime.Str("query")) == 0 { + return common.FlagErrorf("search keyword empty") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + pageSizeStr := runtime.Str("page-size") + pageToken := runtime.Str("page-token") + + pageSize := 20 + if n, err := strconv.Atoi(pageSizeStr); err == nil { + pageSize = int(math.Min(math.Max(float64(n), 1), 200)) + } + + params := map[string]interface{}{ + "query": runtime.Str("query"), + "page_size": pageSize, + } + if pageToken != "" { + params["page_token"] = pageToken + } + + return common.NewDryRunAPI(). + GET("/open-apis/search/v1/user"). + Params(params) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + query := runtime.Str("query") + pageSizeStr := runtime.Str("page-size") + pageToken := runtime.Str("page-token") + + pageSize := 20 + if n, err := strconv.Atoi(pageSizeStr); err == nil { + pageSize = int(math.Min(math.Max(float64(n), 1), 200)) + } + + params := map[string]interface{}{ + "query": query, + "page_size": pageSize, + } + if pageToken != "" { + params["page_token"] = pageToken + } + + data, err := runtime.CallAPI("GET", "/open-apis/search/v1/user", params, nil) + if err != nil { + return err + } + users, _ := data["users"].([]interface{}) + + for _, u := range users { + if m, _ := u.(map[string]interface{}); m != nil { + if av, _ := m["avatar"].(map[string]interface{}); av != nil { + m["avatar"] = map[string]interface{}{"avatar_origin": av["avatar_origin"]} + } + } + } + searchData := map[string]interface{}{ + "users": users, + "has_more": data["has_more"], + "page_token": data["page_token"], + } + runtime.OutFormat(searchData, nil, func(w io.Writer) { + if len(users) == 0 { + fmt.Fprintln(w, "No matching users found.") + return + } + + var rows []map[string]interface{} + for _, u := range users { + m, _ := u.(map[string]interface{}) + rows = append(rows, map[string]interface{}{ + "name": pickUserName(m), + "open_id": m["open_id"], + "email": firstNonEmpty(m, "email", "mail"), + "mobile": firstNonEmpty(m, "mobile", "phone"), + "department": firstNonEmpty(m, "department_name", "department"), + "enterprise_email": firstNonEmpty(m, "enterprise_email"), + }) + } + output.PrintTable(w, rows) + hasMore, _ := data["has_more"].(bool) + moreHint := "" + if hasMore { + pt, _ := data["page_token"].(string) + moreHint = fmt.Sprintf(" (more available, page_token: %s)", pt) + } + fmt.Fprintf(w, "\n%d user(s)%s\n", len(users), moreHint) + }) + return nil + }, +} + +func pickUserName(m map[string]interface{}) string { + for _, key := range []string{"name", "user_name", "display_name", "employee_name", "cn_name"} { + if v, ok := m[key].(string); ok && v != "" { + return v + } + } + return "" +} + +func firstNonEmpty(m map[string]interface{}, keys ...string) string { + for _, key := range keys { + if v, ok := m[key].(string); ok && v != "" { + return v + } + } + return "" +} diff --git a/shortcuts/contact/shortcuts.go b/shortcuts/contact/shortcuts.go new file mode 100644 index 00000000..ace3b0fa --- /dev/null +++ b/shortcuts/contact/shortcuts.go @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all contact shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + ContactSearchUser, + ContactGetUser, + } +} diff --git a/shortcuts/doc/doc_media_download.go b/shortcuts/doc/doc_media_download.go new file mode 100644 index 00000000..80b7c88f --- /dev/null +++ b/shortcuts/doc/doc_media_download.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var mimeToExt = map[string]string{ + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "application/pdf": ".pdf", + "video/mp4": ".mp4", + "text/plain": ".txt", +} + +var DocMediaDownload = common.Shortcut{ + Service: "docs", + Command: "+media-download", + Description: "Download document media or whiteboard thumbnail (auto-detects extension)", + Risk: "read", + Scopes: []string{"docs:document.media:download"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "token", Desc: "resource token (file_token or whiteboard_id)", Required: true}, + {Name: "output", Desc: "local save path", Required: true}, + {Name: "type", Default: "media", Desc: "resource type: media (default) | whiteboard"}, + {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("token") + outputPath := runtime.Str("output") + mediaType := runtime.Str("type") + if mediaType == "whiteboard" { + return common.NewDryRunAPI(). + GET("/open-apis/board/v1/whiteboards/:token/download_as_image"). + Desc("(when --type=whiteboard) Download whiteboard as image"). + Set("token", token).Set("output", outputPath) + } + return common.NewDryRunAPI(). + GET("/open-apis/drive/v1/medias/:token/download"). + Desc("(when --type=media) Download document media file"). + Set("token", token).Set("output", outputPath) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("token") + outputPath := runtime.Str("output") + mediaType := runtime.Str("type") + overwrite := runtime.Bool("overwrite") + + if err := validate.ResourceName(token, "--token"); err != nil { + return output.ErrValidation("%s", err) + } + // Early path validation before API call (final validation after auto-extension below) + if _, err := validate.SafeOutputPath(outputPath); err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s %s\n", mediaType, common.MaskToken(token)) + + // Build API URL + encodedToken := validate.EncodePathSegment(token) + var apiPath string + if mediaType == "whiteboard" { + apiPath = fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", encodedToken) + } else { + apiPath = fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", encodedToken) + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: apiPath, + }, larkcore.WithFileDownload()) + if err != nil { + return output.ErrNetwork("download failed: %v", err) + } + if apiResp.StatusCode >= 400 { + return output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, strings.TrimSpace(string(apiResp.RawBody))) + } + + // Auto-detect extension from Content-Type + finalPath := outputPath + currentExt := filepath.Ext(outputPath) + if currentExt == "" { + contentType := apiResp.Header.Get("Content-Type") + mimeType := strings.Split(contentType, ";")[0] + mimeType = strings.TrimSpace(mimeType) + if ext, ok := mimeToExt[mimeType]; ok { + finalPath = outputPath + ext + } else if mediaType == "whiteboard" { + finalPath = outputPath + ".png" + } + } + + safePath, err := validate.SafeOutputPath(finalPath) + if err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + if err := common.EnsureWritableFile(safePath, overwrite); err != nil { + return err + } + + os.MkdirAll(filepath.Dir(safePath), 0755) + if err := validate.AtomicWrite(safePath, apiResp.RawBody, 0644); err != nil { + return output.Errorf(output.ExitInternal, "io", "cannot create file: %v", err) + } + + runtime.Out(map[string]interface{}{ + "saved_path": safePath, + "size_bytes": len(apiResp.RawBody), + "content_type": apiResp.Header.Get("Content-Type"), + }, nil) + return nil + }, +} diff --git a/shortcuts/doc/doc_media_insert.go b/shortcuts/doc/doc_media_insert.go new file mode 100644 index 00000000..32986f74 --- /dev/null +++ b/shortcuts/doc/doc_media_insert.go @@ -0,0 +1,422 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const maxFileSize = 20 * 1024 * 1024 // 20MB + +var alignMap = map[string]int{ + "left": 1, + "center": 2, + "right": 3, +} + +var DocMediaInsert = common.Shortcut{ + Service: "docs", + Command: "+media-insert", + Description: "Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback)", + Risk: "write", + Scopes: []string{"docs:document.media:upload", "docx:document:write_only", "docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file", Desc: "local file path (max 20MB)", Required: true}, + {Name: "doc", Desc: "document URL or document_id", Required: true}, + {Name: "type", Default: "image", Desc: "type: image | file"}, + {Name: "align", Desc: "alignment: left | center | right"}, + {Name: "caption", Desc: "image caption text"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + docRef, err := parseDocumentRef(runtime.Str("doc")) + if err != nil { + return err + } + if docRef.Kind == "doc" { + return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + docRef, err := parseDocumentRef(runtime.Str("doc")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + documentID := docRef.Token + stepBase := 1 + filePath := runtime.Str("file") + mediaType := runtime.Str("type") + caption := runtime.Str("caption") + + parentType := parentTypeForMediaType(mediaType) + createBlockData := buildCreateBlockData(mediaType, 0) + createBlockData["index"] = "" + batchUpdateData := buildBatchUpdateData("", mediaType, "", runtime.Str("align"), caption) + + d := common.NewDryRunAPI() + if docRef.Kind == "wiki" { + documentID = "" + stepBase = 2 + d.Desc("5-step orchestration: resolve wiki → query root → create block → upload file → bind to block (auto-rollback on failure)"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to docx document"). + Params(map[string]interface{}{"token": docRef.Token}) + } else { + d.Desc("4-step orchestration: query root → create block → upload file → bind to block (auto-rollback on failure)") + } + + d. + GET("/open-apis/docx/v1/documents/:document_id/blocks/:document_id"). + Desc(fmt.Sprintf("[%d] Get document root block", stepBase)). + POST("/open-apis/docx/v1/documents/:document_id/blocks/:document_id/children"). + Desc(fmt.Sprintf("[%d] Create empty block at document end", stepBase+1)). + Body(createBlockData). + POST("/open-apis/drive/v1/medias/upload_all"). + Desc(fmt.Sprintf("[%d] Upload local file (multipart/form-data)", stepBase+2)). + Body(map[string]interface{}{ + "file_name": filepath.Base(filePath), + "parent_type": parentType, + "parent_node": "", + "file": "@" + filePath, + }). + PATCH("/open-apis/docx/v1/documents/:document_id/blocks/batch_update"). + Desc(fmt.Sprintf("[%d] Bind uploaded file token to the new block", stepBase+3)). + Body(batchUpdateData) + + return d.Set("document_id", documentID) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + docInput := runtime.Str("doc") + mediaType := runtime.Str("type") + alignStr := runtime.Str("align") + caption := runtime.Str("caption") + + safeFilePath, pathErr := validate.SafeInputPath(filePath) + if pathErr != nil { + return output.ErrValidation("unsafe file path: %s", pathErr) + } + filePath = safeFilePath + + documentID, err := resolveDocxDocumentID(runtime, docInput) + if err != nil { + return err + } + + // Validate file + stat, err := os.Stat(filePath) + if err != nil { + return output.ErrValidation("file not found: %s", filePath) + } + if stat.Size() > maxFileSize { + return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024) + } + + fileName := filepath.Base(filePath) + fmt.Fprintf(runtime.IO().ErrOut, "Inserting: %s -> document %s\n", fileName, common.MaskToken(documentID)) + + // Step 1: Get document root block to find where to insert + rootData, err := runtime.CallAPI("GET", + fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentID), validate.EncodePathSegment(documentID)), + nil, nil) + if err != nil { + return err + } + + parentBlockID, insertIndex, err := extractAppendTarget(rootData, documentID) + if err != nil { + return err + } + fmt.Fprintf(runtime.IO().ErrOut, "Root block ready: %s (%d children)\n", parentBlockID, insertIndex) + + // Step 2: Create an empty block at the end of the document + fmt.Fprintf(runtime.IO().ErrOut, "Creating block at index %d\n", insertIndex) + + createData, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)), + nil, buildCreateBlockData(mediaType, insertIndex)) + if err != nil { + return err + } + + blockId, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, mediaType) + + if blockId == "" { + return output.Errorf(output.ExitAPI, "api_error", "failed to create block: no block_id returned") + } + + fmt.Fprintf(runtime.IO().ErrOut, "Block created: %s\n", blockId) + if uploadParentNode != blockId || replaceBlockID != blockId { + fmt.Fprintf(runtime.IO().ErrOut, "Resolved file block targets: upload=%s replace=%s\n", uploadParentNode, replaceBlockID) + } + + // Rollback helper + rollback := func() error { + fmt.Fprintf(runtime.IO().ErrOut, "Rolling back: deleting block %s\n", blockId) + _, err := runtime.CallAPI("DELETE", + fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children/batch_delete", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)), + nil, buildDeleteBlockData(insertIndex)) + return err + } + withRollbackWarning := func(opErr error) error { + rollbackErr := rollback() + if rollbackErr == nil { + return opErr + } + warning := fmt.Sprintf("rollback failed for block %s: %v", blockId, rollbackErr) + fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning) + return opErr + } + + // Step 3: Upload media file + fileToken, err := uploadMediaFile(ctx, runtime, filePath, fileName, mediaType, uploadParentNode, documentID) + if err != nil { + return withRollbackWarning(err) + } + + fmt.Fprintf(runtime.IO().ErrOut, "File uploaded: %s\n", fileToken) + + // Step 4: Bind file token to block via batch_update + fmt.Fprintf(runtime.IO().ErrOut, "Binding uploaded media to block %s\n", replaceBlockID) + + if _, err := runtime.CallAPI("PATCH", + fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)), + nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption)); err != nil { + return withRollbackWarning(err) + } + + runtime.Out(map[string]interface{}{ + "document_id": documentID, + "block_id": blockId, + "file_token": fileToken, + "type": mediaType, + }, nil) + return nil + }, +} + +func blockTypeForMediaType(mediaType string) int { + if mediaType == "file" { + return 23 + } + return 27 +} + +func parentTypeForMediaType(mediaType string) string { + if mediaType == "file" { + return "docx_file" + } + return "docx_image" +} + +func buildCreateBlockData(mediaType string, index int) map[string]interface{} { + child := map[string]interface{}{ + "block_type": blockTypeForMediaType(mediaType), + } + if mediaType == "file" { + child["file"] = map[string]interface{}{} + } else { + child["image"] = map[string]interface{}{} + } + return map[string]interface{}{ + "children": []interface{}{ + child, + }, + "index": index, + } +} + +func buildDeleteBlockData(index int) map[string]interface{} { + return map[string]interface{}{ + "start_index": index, + "end_index": index + 1, + } +} + +func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string, error) { + docRef, err := parseDocumentRef(input) + if err != nil { + return "", err + } + + switch docRef.Kind { + case "docx": + return docRef.Token, nil + case "doc": + return "", output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx") + case "wiki": + fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token)) + data, err := runtime.CallAPI( + "GET", + "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{"token": docRef.Token}, + nil, + ) + if err != nil { + return "", err + } + + node := common.GetMap(data, "node") + objType := common.GetString(node, "obj_type") + objToken := common.GetString(node, "obj_token") + if objType == "" || objToken == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data") + } + if objType != "docx" { + return "", output.ErrValidation("wiki resolved to %q, but docs +media-insert only supports docx documents", objType) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken)) + return objToken, nil + default: + return "", output.ErrValidation("docs +media-insert only supports docx documents") + } +} + +func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string) map[string]interface{} { + request := map[string]interface{}{ + "block_id": blockID, + } + if mediaType == "file" { + request["replace_file"] = map[string]interface{}{ + "token": fileToken, + } + } else { + replaceImage := map[string]interface{}{ + "token": fileToken, + } + if alignVal, ok := alignMap[alignStr]; ok { + replaceImage["align"] = alignVal + } + if caption != "" { + replaceImage["caption"] = map[string]interface{}{ + "content": caption, + } + } + request["replace_image"] = replaceImage + } + return map[string]interface{}{ + "requests": []interface{}{request}, + } +} + +func extractAppendTarget(rootData map[string]interface{}, fallbackBlockID string) (string, int, error) { + block, _ := rootData["block"].(map[string]interface{}) + if len(block) == 0 { + return "", 0, output.Errorf(output.ExitAPI, "api_error", "failed to query document root block") + } + + parentBlockID := fallbackBlockID + if blockID, _ := block["block_id"].(string); blockID != "" { + parentBlockID = blockID + } + + children, _ := block["children"].([]interface{}) + return parentBlockID, len(children), nil +} + +func extractCreatedBlockTargets(createData map[string]interface{}, mediaType string) (blockID, uploadParentNode, replaceBlockID string) { + children, _ := createData["children"].([]interface{}) + if len(children) == 0 { + return "", "", "" + } + + child, _ := children[0].(map[string]interface{}) + blockID, _ = child["block_id"].(string) + uploadParentNode = blockID + replaceBlockID = blockID + + if mediaType != "file" { + return blockID, uploadParentNode, replaceBlockID + } + + nestedChildren, _ := child["children"].([]interface{}) + if len(nestedChildren) == 0 { + return blockID, uploadParentNode, replaceBlockID + } + if nestedBlockID, ok := nestedChildren[0].(string); ok && nestedBlockID != "" { + uploadParentNode = nestedBlockID + replaceBlockID = nestedBlockID + } + return blockID, uploadParentNode, replaceBlockID +} + +// uploadMediaFile uploads a file to Feishu drive as media. +func uploadMediaFile(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, mediaType, parentNode, docId string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return "", output.Errorf(output.ExitInternal, "internal_error", "failed to stat file: %v", err) + } + fileSize := stat.Size() + + parentType := parentTypeForMediaType(mediaType) + + // Build SDK Formdata + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", parentType) + fd.AddField("parent_node", parentNode) + fd.AddField("size", fmt.Sprintf("%d", fileSize)) + if docId != "" { + extra, err := buildDriveRouteExtra(docId) + if err != nil { + return "", err + } + fd.AddField("extra", extra) + } + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/medias/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return "", err + } + return "", output.ErrNetwork("file upload failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return "", output.Errorf(output.ExitAPI, "api_error", "file upload failed: invalid response JSON: %v", err) + } + + code, _ := util.ToFloat64(result["code"]) + if code != 0 { + msg, _ := result["msg"].(string) + return "", output.ErrAPI(int(code), fmt.Sprintf("file upload failed: [%d] %s", int(code), msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + if fileToken == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "file upload failed: no file_token returned") + } + + return fileToken, nil +} diff --git a/shortcuts/doc/doc_media_insert_test.go b/shortcuts/doc/doc_media_insert_test.go new file mode 100644 index 00000000..0e4f9bad --- /dev/null +++ b/shortcuts/doc/doc_media_insert_test.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "reflect" + "testing" +) + +func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) { + t.Parallel() + + got := buildCreateBlockData("image", 3) + want := map[string]interface{}{ + "children": []interface{}{ + map[string]interface{}{ + "block_type": 27, + "image": map[string]interface{}{}, + }, + }, + "index": 3, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildCreateBlockData() = %#v, want %#v", got, want) + } +} + +func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) { + t.Parallel() + + got := buildCreateBlockData("file", 1) + want := map[string]interface{}{ + "children": []interface{}{ + map[string]interface{}{ + "block_type": 23, + "file": map[string]interface{}{}, + }, + }, + "index": 1, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildCreateBlockData(file) = %#v, want %#v", got, want) + } +} + +func TestBuildDeleteBlockDataUsesHalfOpenInterval(t *testing.T) { + t.Parallel() + + got := buildDeleteBlockData(5) + want := map[string]interface{}{ + "start_index": 5, + "end_index": 6, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildDeleteBlockData() = %#v, want %#v", got, want) + } +} + +func TestBuildBatchUpdateDataForImage(t *testing.T) { + t.Parallel() + + got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text") + want := map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "block_id": "blk_1", + "replace_image": map[string]interface{}{ + "token": "file_tok", + "align": 2, + "caption": map[string]interface{}{ + "content": "caption text", + }, + }, + }, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildBatchUpdateData(image) = %#v, want %#v", got, want) + } +} + +func TestBuildBatchUpdateDataForFile(t *testing.T) { + t.Parallel() + + got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "") + want := map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "block_id": "blk_2", + "replace_file": map[string]interface{}{ + "token": "file_tok", + }, + }, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildBatchUpdateData(file) = %#v, want %#v", got, want) + } +} + +func TestExtractAppendTargetUsesRootChildrenCount(t *testing.T) { + t.Parallel() + + rootData := map[string]interface{}{ + "block": map[string]interface{}{ + "block_id": "root_block", + "children": []interface{}{"c1", "c2", "c3"}, + }, + } + + blockID, index, err := extractAppendTarget(rootData, "fallback") + if err != nil { + t.Fatalf("extractAppendTarget() unexpected error: %v", err) + } + if blockID != "root_block" { + t.Fatalf("extractAppendTarget() blockID = %q, want %q", blockID, "root_block") + } + if index != 3 { + t.Fatalf("extractAppendTarget() index = %d, want 3", index) + } +} + +func TestExtractCreatedBlockTargetsForImage(t *testing.T) { + t.Parallel() + + createData := map[string]interface{}{ + "children": []interface{}{ + map[string]interface{}{ + "block_id": "img_outer", + }, + }, + } + + blockID, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, "image") + if blockID != "img_outer" || uploadParentNode != "img_outer" || replaceBlockID != "img_outer" { + t.Fatalf("extractCreatedBlockTargets(image) = (%q, %q, %q)", blockID, uploadParentNode, replaceBlockID) + } +} + +func TestExtractCreatedBlockTargetsForFileUsesNestedFileBlock(t *testing.T) { + t.Parallel() + + createData := map[string]interface{}{ + "children": []interface{}{ + map[string]interface{}{ + "block_id": "view_outer", + "children": []interface{}{"file_inner"}, + }, + }, + } + + blockID, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, "file") + if blockID != "view_outer" { + t.Fatalf("extractCreatedBlockTargets(file) blockID = %q, want %q", blockID, "view_outer") + } + if uploadParentNode != "file_inner" { + t.Fatalf("extractCreatedBlockTargets(file) uploadParentNode = %q, want %q", uploadParentNode, "file_inner") + } + if replaceBlockID != "file_inner" { + t.Fatalf("extractCreatedBlockTargets(file) replaceBlockID = %q, want %q", replaceBlockID, "file_inner") + } +} diff --git a/shortcuts/doc/doc_media_test.go b/shortcuts/doc/doc_media_test.go new file mode 100644 index 00000000..7e805213 --- /dev/null +++ b/shortcuts/doc/doc_media_test.go @@ -0,0 +1,209 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func docsTestConfig() *core.CliConfig { + return docsTestConfigWithAppID("docs-test-app") +} + +func docsTestConfigWithAppID(appID string) *core.CliConfig { + return &core.CliConfig{ + AppID: appID, AppSecret: "test-secret", Brand: core.BrandFeishu, + } +} + +func mountAndRunDocs(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "docs"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func withDocsWorkingDir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error: %v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("restore cwd error: %v", err) + } + }) +} + +func registerDocsBotTokenStub(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token", "expire": 7200, + }, + }) +} + +func TestDocMediaInsertRejectsOldDocURL(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfig()) + + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", "https://example.larksuite.com/doc/xxxxxx", + "--file", "dummy.png", + "--dry-run", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "only supports docx documents") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDocMediaInsertDryRunWikiAddsResolveStep(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfig()) + + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", "https://example.larksuite.com/wiki/xxxxxx", + "--file", "dummy.png", + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "Resolve wiki node to docx document") { + t.Fatalf("dry-run output missing wiki resolve step: %s", out) + } + if !strings.Contains(out, "resolved_docx_token") { + t.Fatalf("dry-run output missing resolved docx token placeholder: %s", out) + } +} + +func TestDocMediaInsertExecuteResolvesWikiBeforeFileCheck(t *testing.T) { + f, _, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-insert-exec-app")) + registerDocsBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "docx", + "obj_token": "doxcnResolved123", + }, + }, + }, + }) + + tmpDir := t.TempDir() + withDocsWorkingDir(t, tmpDir) + + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", "https://example.larksuite.com/wiki/xxxxxx", + "--file", "missing.png", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected file-not-found error, got nil") + } + if !strings.Contains(err.Error(), "file not found") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stderr.String(), "Resolved wiki to docx") { + t.Fatalf("stderr missing wiki resolution log: %s", stderr.String()) + } +} + +func TestDocMediaDownloadRejectsOverwriteWithoutFlag(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-overwrite-app")) + registerDocsBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/medias/tok_123/download", + Status: 200, + Body: []byte("new"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + tmpDir := t.TempDir() + withDocsWorkingDir(t, tmpDir) + if err := os.WriteFile("download.bin", []byte("old"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDocs(t, DocMediaDownload, []string{ + "+media-download", + "--token", "tok_123", + "--output", "download.bin", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected overwrite protection error, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDocMediaDownloadRejectsHTTPErrorBeforeWrite(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-app")) + registerDocsBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/medias/tok_123/download", + Status: 404, + Body: "not found", + Headers: http.Header{"Content-Type": []string{"text/plain"}}, + }) + + tmpDir := t.TempDir() + withDocsWorkingDir(t, tmpDir) + + err := mountAndRunDocs(t, DocMediaDownload, []string{ + "+media-download", + "--token", "tok_123", + "--output", "download.bin", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected HTTP error, got nil") + } + if !strings.Contains(err.Error(), "HTTP 404") { + t.Fatalf("unexpected error: %v", err) + } + if _, statErr := os.Stat(filepath.Join(tmpDir, "download.bin")); !os.IsNotExist(statErr) { + t.Fatalf("download target should not be created, statErr=%v", statErr) + } +} diff --git a/shortcuts/doc/doc_media_upload.go b/shortcuts/doc/doc_media_upload.go new file mode 100644 index 00000000..008597b8 --- /dev/null +++ b/shortcuts/doc/doc_media_upload.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var MediaUpload = common.Shortcut{ + Service: "docs", + Command: "+media-upload", + Description: "Upload media file (image/attachment) to a document block", + Risk: "write", + Scopes: []string{"docs:document.media:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file", Desc: "local file path (max 20MB)", Required: true}, + {Name: "parent-type", Desc: "parent type: docx_image | docx_file", Required: true}, + {Name: "parent-node", Desc: "parent node ID (block_id)", Required: true}, + {Name: "doc-id", Desc: "document ID (for drive_route_token)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + filePath := runtime.Str("file") + parentType := runtime.Str("parent-type") + parentNode := runtime.Str("parent-node") + docId := runtime.Str("doc-id") + body := map[string]interface{}{ + "file_name": filepath.Base(filePath), + "parent_type": parentType, + "parent_node": parentNode, + "file": "@" + filePath, + } + if docId != "" { + body["extra"] = fmt.Sprintf(`{"drive_route_token":"%s"}`, docId) + } + return common.NewDryRunAPI(). + Desc("multipart/form-data upload"). + POST("/open-apis/drive/v1/medias/upload_all"). + Body(body) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + parentType := runtime.Str("parent-type") + parentNode := runtime.Str("parent-node") + docId := runtime.Str("doc-id") + + safeFilePath, pathErr := validate.SafeInputPath(filePath) + if pathErr != nil { + return output.ErrValidation("unsafe file path: %s", pathErr) + } + filePath = safeFilePath + + // Validate file + stat, err := os.Stat(filePath) + if err != nil { + return output.ErrValidation("file not found: %s", filePath) + } + if stat.Size() > maxFileSize { + return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024) + } + + fileName := filepath.Base(filePath) + fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%d bytes)\n", fileName, stat.Size()) + + f, err := os.Open(filePath) + if err != nil { + return output.ErrValidation("cannot open file: %v", err) + } + defer f.Close() + + // Build SDK Formdata + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", parentType) + fd.AddField("parent_node", parentNode) + fd.AddField("size", fmt.Sprintf("%d", stat.Size())) + if docId != "" { + extra, err := buildDriveRouteExtra(docId) + if err != nil { + return err + } + fd.AddField("extra", extra) + } + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/medias/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return err + } + return output.ErrNetwork("upload failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) + } + + code, _ := util.ToFloat64(result["code"]) + if code != 0 { + msg, _ := result["msg"].(string) + return output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + if fileToken == "" { + return output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + } + + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "size": stat.Size(), + }, nil) + return nil + }, +} diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go new file mode 100644 index 00000000..c88c6eaf --- /dev/null +++ b/shortcuts/doc/docs_create.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var DocsCreate = common.Shortcut{ + Service: "docs", + Command: "+create", + Description: "Create a Lark document", + Risk: "write", + AuthTypes: []string{"user", "bot"}, + Scopes: []string{"docx:document:create"}, + Flags: []common.Flag{ + {Name: "title", Desc: "document title"}, + {Name: "markdown", Desc: "Markdown content (Lark-flavored)", Required: true}, + {Name: "folder-token", Desc: "parent folder token"}, + {Name: "wiki-node", Desc: "wiki node token"}, + {Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + count := 0 + if runtime.Str("folder-token") != "" { + count++ + } + if runtime.Str("wiki-node") != "" { + count++ + } + if runtime.Str("wiki-space") != "" { + count++ + } + if count > 1 { + return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + args := map[string]interface{}{ + "markdown": runtime.Str("markdown"), + } + if v := runtime.Str("title"); v != "" { + args["title"] = v + } + if v := runtime.Str("folder-token"); v != "" { + args["folder_token"] = v + } + if v := runtime.Str("wiki-node"); v != "" { + args["wiki_node"] = v + } + if v := runtime.Str("wiki-space"); v != "" { + args["wiki_space"] = v + } + return common.NewDryRunAPI(). + POST(common.MCPEndpoint(runtime.Config.Brand)). + Desc("MCP tool: create-doc"). + Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}). + Set("mcp_tool", "create-doc").Set("args", args) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + args := map[string]interface{}{ + "markdown": runtime.Str("markdown"), + } + if v := runtime.Str("title"); v != "" { + args["title"] = v + } + if v := runtime.Str("folder-token"); v != "" { + args["folder_token"] = v + } + if v := runtime.Str("wiki-node"); v != "" { + args["wiki_node"] = v + } + if v := runtime.Str("wiki-space"); v != "" { + args["wiki_space"] = v + } + + result, err := common.CallMCPTool(runtime, "create-doc", args) + if err != nil { + return err + } + + runtime.Out(result, nil) + return nil + }, +} diff --git a/shortcuts/doc/docs_fetch.go b/shortcuts/doc/docs_fetch.go new file mode 100644 index 00000000..65a4e890 --- /dev/null +++ b/shortcuts/doc/docs_fetch.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "fmt" + "io" + "strconv" + + "github.com/larksuite/cli/shortcuts/common" +) + +var DocsFetch = common.Shortcut{ + Service: "docs", + Command: "+fetch", + Description: "Fetch Lark document content", + Risk: "read", + Scopes: []string{"docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "offset", Desc: "pagination offset"}, + {Name: "limit", Desc: "pagination limit"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + args := map[string]interface{}{ + "doc_id": runtime.Str("doc"), + } + if v := runtime.Str("offset"); v != "" { + n, _ := strconv.Atoi(v) + args["offset"] = n + } + if v := runtime.Str("limit"); v != "" { + n, _ := strconv.Atoi(v) + args["limit"] = n + } + return common.NewDryRunAPI(). + POST(common.MCPEndpoint(runtime.Config.Brand)). + Desc("MCP tool: fetch-doc"). + Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}). + Set("mcp_tool", "fetch-doc").Set("args", args) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + args := map[string]interface{}{ + "doc_id": runtime.Str("doc"), + } + if v := runtime.Str("offset"); v != "" { + n, _ := strconv.Atoi(v) + args["offset"] = n + } + if v := runtime.Str("limit"); v != "" { + n, _ := strconv.Atoi(v) + args["limit"] = n + } + + result, err := common.CallMCPTool(runtime, "fetch-doc", args) + if err != nil { + return err + } + + runtime.OutFormat(result, nil, func(w io.Writer) { + if title, ok := result["title"].(string); ok && title != "" { + fmt.Fprintf(w, "# %s\n\n", title) + } + if md, ok := result["markdown"].(string); ok { + fmt.Fprintln(w, md) + } + if hasMore, ok := result["has_more"].(bool); ok && hasMore { + fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---") + } + }) + return nil + }, +} diff --git a/shortcuts/doc/docs_search.go b/shortcuts/doc/docs_search.go new file mode 100644 index 00000000..9824c3f1 --- /dev/null +++ b/shortcuts/doc/docs_search.go @@ -0,0 +1,306 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "regexp" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var DocsSearch = common.Shortcut{ + Service: "docs", + Command: "+search", + Description: "Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search)", + Risk: "read", + Scopes: []string{"search:docs:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "query", Desc: "search keyword"}, + {Name: "filter", Desc: "filter conditions (JSON object)"}, + {Name: "page-token", Desc: "page token"}, + {Name: "page-size", Default: "15", Desc: "page size (default 15, max 20)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + requestData, err := buildDocsSearchRequest( + runtime.Str("query"), + runtime.Str("filter"), + runtime.Str("page-token"), + runtime.Str("page-size"), + ) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + return common.NewDryRunAPI(). + POST("/open-apis/search/v2/doc_wiki/search"). + Body(requestData) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + requestData, err := buildDocsSearchRequest( + runtime.Str("query"), + runtime.Str("filter"), + runtime.Str("page-token"), + runtime.Str("page-size"), + ) + if err != nil { + return err + } + + data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData) + if err != nil { + return err + } + items, _ := data["res_units"].([]interface{}) + + // Add ISO time fields + normalizedItems := addIsoTimeFields(items) + + resultData := map[string]interface{}{ + "total": data["total"], + "has_more": data["has_more"], + "page_token": data["page_token"], + "results": normalizedItems, + } + + runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) { + if len(normalizedItems) == 0 { + fmt.Fprintln(w, "No matching results found.") + return + } + + // Table output + htmlTagRe := regexp.MustCompile(``) + var rows []map[string]interface{} + for _, item := range normalizedItems { + u, _ := item.(map[string]interface{}) + if u == nil { + continue + } + + rawTitle := fmt.Sprintf("%v", u["title_highlighted"]) + title := htmlTagRe.ReplaceAllString(rawTitle, "") + title = common.TruncateStr(title, 50) + + resultMeta, _ := u["result_meta"].(map[string]interface{}) + docTypes := "" + if resultMeta != nil { + docTypes = fmt.Sprintf("%v", resultMeta["doc_types"]) + } + entityType := fmt.Sprintf("%v", u["entity_type"]) + typeStr := docTypes + if typeStr == "" || typeStr == "" { + typeStr = entityType + } + + url := "" + editTime := "" + if resultMeta != nil { + url = fmt.Sprintf("%v", resultMeta["url"]) + editTime = fmt.Sprintf("%v", resultMeta["update_time_iso"]) + } + if len(url) > 80 { + url = url[:80] + } + + rows = append(rows, map[string]interface{}{ + "type": typeStr, + "title": title, + "edit_time": editTime, + "url": url, + }) + } + + output.PrintTable(w, rows) + moreHint := "" + hasMore, _ := data["has_more"].(bool) + if hasMore { + moreHint = " (more available, use --format json to get page_token, then --page-token to paginate)" + } + fmt.Fprintf(w, "\n%d result(s)%s\n", len(rows), moreHint) + }) + return nil + }, +} + +func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (map[string]interface{}, error) { + pageSize, _ := strconv.Atoi(pageSizeStr) + if pageSize <= 0 { + pageSize = 15 + } + if pageSize > 20 { + pageSize = 20 + } + + requestData := map[string]interface{}{ + "query": query, + "page_size": pageSize, + } + if pageToken != "" { + requestData["page_token"] = pageToken + } + + if filterStr == "" { + requestData["doc_filter"] = map[string]interface{}{} + requestData["wiki_filter"] = map[string]interface{}{} + return requestData, nil + } + + var filter map[string]interface{} + if err := json.Unmarshal([]byte(filterStr), &filter); err != nil { + return nil, output.ErrValidation("--filter is not valid JSON") + } + if err := convertTimeRangeInFilter(filter, "open_time"); err != nil { + return nil, err + } + if err := convertTimeRangeInFilter(filter, "create_time"); err != nil { + return nil, err + } + + requestData["doc_filter"] = filter + wikiFilter := make(map[string]interface{}, len(filter)) + for k, v := range filter { + wikiFilter[k] = v + } + requestData["wiki_filter"] = wikiFilter + return requestData, nil +} + +// convertTimeRangeInFilter converts ISO 8601 time range to Unix seconds. +func convertTimeRangeInFilter(filter map[string]interface{}, key string) error { + val, ok := filter[key] + if !ok { + return nil + } + rangeMap, ok := val.(map[string]interface{}) + if !ok { + return nil + } + + result := make(map[string]interface{}) + if start, ok := rangeMap["start"].(string); ok && start != "" { + startTime, err := toUnixSeconds(start) + if err != nil { + return output.ErrValidation("invalid %s.start %q: %s", key, start, err) + } + result["start"] = startTime + } + if end, ok := rangeMap["end"].(string); ok && end != "" { + endTime, err := toUnixSeconds(end) + if err != nil { + return output.ErrValidation("invalid %s.end %q: %s", key, end, err) + } + result["end"] = endTime + } + filter[key] = result + return nil +} + +func toUnixSeconds(input string) (int64, error) { + formats := []string{ + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, f := range formats { + if t, err := time.ParseInLocation(f, input, time.Local); err == nil { + return t.Unix(), nil + } + } + // Try as number + if n, err := strconv.ParseInt(input, 10, 64); err == nil { + return n, nil + } + return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds") +} + +func unixTimestampToISO8601(v interface{}) string { + if v == nil { + return "" + } + + var num float64 + switch val := v.(type) { + case float64: + num = val + case json.Number: + parsed, err := val.Float64() + if err != nil { + return "" + } + num = parsed + case string: + parsed, err := strconv.ParseFloat(val, 64) + if err != nil { + return "" + } + num = parsed + default: + return "" + } + + if math.IsInf(num, 0) || math.IsNaN(num) { + return "" + } + + // Heuristic: >= 1e12 treat as ms, else seconds + ms := int64(num) + if num >= 1e12 { + ms = ms / 1000 + } + t := time.Unix(ms, 0) + return t.Format(time.RFC3339) +} + +// addIsoTimeFields recursively adds *_time_iso fields. +func addIsoTimeFields(value interface{}) []interface{} { + if arr, ok := value.([]interface{}); ok { + result := make([]interface{}, len(arr)) + for i, item := range arr { + result[i] = addIsoTimeFieldsOne(item) + } + return result + } + return nil +} + +func addIsoTimeFieldsOne(value interface{}) interface{} { + switch v := value.(type) { + case []interface{}: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = addIsoTimeFieldsOne(item) + } + return result + case map[string]interface{}: + out := make(map[string]interface{}) + for key, item := range v { + if strings.HasSuffix(key, "_time_iso") { + out[key] = item + continue + } + out[key] = addIsoTimeFieldsOne(item) + if strings.HasSuffix(key, "_time") { + iso := unixTimestampToISO8601(item) + if iso != "" { + out[key+"_iso"] = iso + } + } + } + return out + default: + return value + } +} diff --git a/shortcuts/doc/docs_search_test.go b/shortcuts/doc/docs_search_test.go new file mode 100644 index 00000000..36f16f81 --- /dev/null +++ b/shortcuts/doc/docs_search_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestAddIsoTimeFieldsSupportsJSONNumber(t *testing.T) { + t.Parallel() + + items := []interface{}{ + map[string]interface{}{ + "result_meta": map[string]interface{}{ + "update_time": json.Number("1774429274"), + }, + }, + } + + got := addIsoTimeFields(items) + item, _ := got[0].(map[string]interface{}) + meta, _ := item["result_meta"].(map[string]interface{}) + want := unixTimestampToISO8601("1774429274") + if meta["update_time_iso"] != want { + t.Fatalf("update_time_iso = %v, want %q", meta["update_time_iso"], want) + } +} + +func TestToUnixSeconds(t *testing.T) { + t.Parallel() + + got, err := toUnixSeconds("2026-03-25") + if err != nil { + t.Fatalf("toUnixSeconds() unexpected error: %v", err) + } + if got <= 0 { + t.Fatalf("toUnixSeconds() = %d, want positive unix timestamp", got) + } +} + +func TestToUnixSecondsRejectsInvalidInput(t *testing.T) { + t.Parallel() + + if _, err := toUnixSeconds("not-a-time"); err == nil { + t.Fatalf("expected invalid time error, got nil") + } +} + +func TestBuildDocsSearchRequestRejectsInvalidTime(t *testing.T) { + t.Parallel() + + _, err := buildDocsSearchRequest( + "query", + `{"open_time":{"start":"not-a-time"}}`, + "", + "15", + ) + if err == nil { + t.Fatalf("expected invalid time error, got nil") + } + if !strings.Contains(err.Error(), "invalid open_time.start") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildDocsSearchRequestUsesStartAndEndKeys(t *testing.T) { + t.Parallel() + + req, err := buildDocsSearchRequest( + "query", + `{"open_time":{"start":"2026-03-25","end":"2026-03-26"}}`, + "", + "15", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + docFilter, ok := req["doc_filter"].(map[string]interface{}) + if !ok { + t.Fatalf("doc_filter has unexpected type %T", req["doc_filter"]) + } + openTime, ok := docFilter["open_time"].(map[string]interface{}) + if !ok { + t.Fatalf("open_time has unexpected type %T", docFilter["open_time"]) + } + if _, ok := openTime["start"]; !ok { + t.Fatalf("expected start in open_time filter, got %#v", openTime) + } + if _, ok := openTime["end"]; !ok { + t.Fatalf("expected end in open_time filter, got %#v", openTime) + } + if _, ok := openTime["start_time"]; ok { + t.Fatalf("did not expect start_time in open_time filter, got %#v", openTime) + } + if _, ok := openTime["end_time"]; ok { + t.Fatalf("did not expect end_time in open_time filter, got %#v", openTime) + } +} diff --git a/shortcuts/doc/docs_update.go b/shortcuts/doc/docs_update.go new file mode 100644 index 00000000..5c64b7cc --- /dev/null +++ b/shortcuts/doc/docs_update.go @@ -0,0 +1,158 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var validModes = map[string]bool{ + "append": true, + "overwrite": true, + "replace_range": true, + "replace_all": true, + "insert_before": true, + "insert_after": true, + "delete_range": true, +} + +var needsSelection = map[string]bool{ + "replace_range": true, + "replace_all": true, + "insert_before": true, + "insert_after": true, + "delete_range": true, +} + +var DocsUpdate = common.Shortcut{ + Service: "docs", + Command: "+update", + Description: "Update a Lark document", + Risk: "write", + Scopes: []string{"docx:document:write_only", "docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Required: true}, + {Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with , repeat to create multiple boards)"}, + {Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')"}, + {Name: "selection-by-title", Desc: "title locator (e.g. '## Section')"}, + {Name: "new-title", Desc: "also update document title"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + mode := runtime.Str("mode") + if !validModes[mode] { + return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode) + } + + if mode != "delete_range" && runtime.Str("markdown") == "" { + return common.FlagErrorf("--%s mode requires --markdown", mode) + } + + selEllipsis := runtime.Str("selection-with-ellipsis") + selTitle := runtime.Str("selection-by-title") + if selEllipsis != "" && selTitle != "" { + return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive") + } + + if needsSelection[mode] && selEllipsis == "" && selTitle == "" { + return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode) + } + + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + args := map[string]interface{}{ + "doc_id": runtime.Str("doc"), + "mode": runtime.Str("mode"), + } + if v := runtime.Str("markdown"); v != "" { + args["markdown"] = v + } + if v := runtime.Str("selection-with-ellipsis"); v != "" { + args["selection_with_ellipsis"] = v + } + if v := runtime.Str("selection-by-title"); v != "" { + args["selection_by_title"] = v + } + if v := runtime.Str("new-title"); v != "" { + args["new_title"] = v + } + return common.NewDryRunAPI(). + POST(common.MCPEndpoint(runtime.Config.Brand)). + Desc("MCP tool: update-doc"). + Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}). + Set("mcp_tool", "update-doc").Set("args", args) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + args := map[string]interface{}{ + "doc_id": runtime.Str("doc"), + "mode": runtime.Str("mode"), + } + if v := runtime.Str("markdown"); v != "" { + args["markdown"] = v + } + if v := runtime.Str("selection-with-ellipsis"); v != "" { + args["selection_with_ellipsis"] = v + } + if v := runtime.Str("selection-by-title"); v != "" { + args["selection_by_title"] = v + } + if v := runtime.Str("new-title"); v != "" { + args["new_title"] = v + } + + result, err := common.CallMCPTool(runtime, "update-doc", args) + if err != nil { + return err + } + + normalizeDocsUpdateResult(result, runtime.Str("markdown")) + runtime.Out(result, nil) + return nil + }, +} + +func normalizeDocsUpdateResult(result map[string]interface{}, markdown string) { + if !isWhiteboardCreateMarkdown(markdown) { + return + } + result["board_tokens"] = normalizeBoardTokens(result["board_tokens"]) +} + +func isWhiteboardCreateMarkdown(markdown string) bool { + lower := strings.ToLower(markdown) + if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") { + return true + } + return strings.Contains(lower, "\n" + if !isWhiteboardCreateMarkdown(markdown) { + t.Fatalf("expected blank whiteboard markdown to be treated as whiteboard creation") + } + }) + + t.Run("mermaid code block", func(t *testing.T) { + markdown := "```mermaid\ngraph TD\nA-->B\n```" + if !isWhiteboardCreateMarkdown(markdown) { + t.Fatalf("expected mermaid markdown to be treated as whiteboard creation") + } + }) + + t.Run("plain markdown", func(t *testing.T) { + markdown := "## plain text" + if isWhiteboardCreateMarkdown(markdown) { + t.Fatalf("did not expect plain markdown to be treated as whiteboard creation") + } + }) +} + +func TestNormalizeDocsUpdateResult(t *testing.T) { + t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) { + result := map[string]interface{}{ + "success": true, + } + + normalizeDocsUpdateResult(result, "") + + got, ok := result["board_tokens"].([]string) + if !ok { + t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"]) + } + if len(got) != 0 { + t.Fatalf("expected empty board_tokens, got %#v", got) + } + }) + + t.Run("normalizes board_tokens to string slice", func(t *testing.T) { + result := map[string]interface{}{ + "board_tokens": []interface{}{"board_1", "board_2"}, + } + + normalizeDocsUpdateResult(result, "") + + want := []string{"board_1", "board_2"} + got, ok := result["board_tokens"].([]string) + if !ok { + t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"]) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("board_tokens mismatch: got %#v want %#v", got, want) + } + }) + + t.Run("leaves non whiteboard response unchanged", func(t *testing.T) { + result := map[string]interface{}{ + "success": true, + } + + normalizeDocsUpdateResult(result, "## plain text") + + if _, ok := result["board_tokens"]; ok { + t.Fatalf("did not expect board_tokens for non-whiteboard markdown") + } + }) +} diff --git a/shortcuts/doc/helpers.go b/shortcuts/doc/helpers.go new file mode 100644 index 00000000..fb58251d --- /dev/null +++ b/shortcuts/doc/helpers.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "encoding/json" + "strings" + + "github.com/larksuite/cli/internal/output" +) + +type documentRef struct { + Kind string + Token string +} + +func parseDocumentRef(input string) (documentRef, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return documentRef{}, output.ErrValidation("--doc cannot be empty") + } + + if token, ok := extractDocumentToken(raw, "/wiki/"); ok { + return documentRef{Kind: "wiki", Token: token}, nil + } + if token, ok := extractDocumentToken(raw, "/docx/"); ok { + return documentRef{Kind: "docx", Token: token}, nil + } + if token, ok := extractDocumentToken(raw, "/doc/"); ok { + return documentRef{Kind: "doc", Token: token}, nil + } + if strings.Contains(raw, "://") { + return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw) + } + if strings.ContainsAny(raw, "/?#") { + return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw) + } + + return documentRef{Kind: "docx", Token: raw}, nil +} + +func extractDocumentToken(raw, marker string) (string, bool) { + idx := strings.Index(raw, marker) + if idx < 0 { + return "", false + } + token := raw[idx+len(marker):] + if end := strings.IndexAny(token, "/?#"); end >= 0 { + token = token[:end] + } + token = strings.TrimSpace(token) + if token == "" { + return "", false + } + return token, true +} + +func buildDriveRouteExtra(docID string) (string, error) { + extra, err := json.Marshal(map[string]string{"drive_route_token": docID}) + if err != nil { + return "", output.Errorf(output.ExitInternal, "internal_error", "failed to marshal upload extra data: %v", err) + } + return string(extra), nil +} diff --git a/shortcuts/doc/helpers_test.go b/shortcuts/doc/helpers_test.go new file mode 100644 index 00000000..22331500 --- /dev/null +++ b/shortcuts/doc/helpers_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "strings" + "testing" +) + +func TestParseDocumentRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantKind string + wantToken string + wantErr string + }{ + { + name: "docx url", + input: "https://example.larksuite.com/docx/xxxxxx?from=wiki", + wantKind: "docx", + wantToken: "xxxxxx", + }, + { + name: "wiki url", + input: "https://example.larksuite.com/wiki/xxxxxx?from=wiki", + wantKind: "wiki", + wantToken: "xxxxxx", + }, + { + name: "doc url", + input: "https://example.larksuite.com/doc/xxxxxx", + wantKind: "doc", + wantToken: "xxxxxx", + }, + { + name: "raw token", + input: "xxxxxx", + wantKind: "docx", + wantToken: "xxxxxx", + }, + { + name: "unsupported url", + input: "https://example.com/not-a-doc", + wantErr: "unsupported --doc input", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parseDocumentRef(tt.input) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Kind != tt.wantKind { + t.Fatalf("parseDocumentRef(%q) kind = %q, want %q", tt.input, got.Kind, tt.wantKind) + } + if got.Token != tt.wantToken { + t.Fatalf("parseDocumentRef(%q) token = %q, want %q", tt.input, got.Token, tt.wantToken) + } + }) + } +} + +func TestBuildDriveRouteExtraEscapesJSON(t *testing.T) { + t.Parallel() + + got, err := buildDriveRouteExtra(`doc-"quoted"`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"drive_route_token":"doc-\"quoted\""}` + if got != want { + t.Fatalf("buildDriveRouteExtra() = %q, want %q", got, want) + } +} diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go new file mode 100644 index 00000000..36ac1893 --- /dev/null +++ b/shortcuts/doc/shortcuts.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all docs shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + DocsSearch, + DocsCreate, + DocsFetch, + DocsUpdate, + DocMediaInsert, + DocMediaDownload, + } +} diff --git a/shortcuts/drive/drive_add_comment.go b/shortcuts/drive/drive_add_comment.go new file mode 100644 index 00000000..cd72a740 --- /dev/null +++ b/shortcuts/drive/drive_add_comment.go @@ -0,0 +1,593 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "unicode/utf8" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const defaultLocateDocLimit = 10 + +type commentDocRef struct { + Kind string + Token string +} + +type resolvedCommentTarget struct { + DocID string + FileToken string + FileType string + ResolvedBy string + WikiToken string +} + +type locateDocBlock struct { + BlockID string + RawMarkdown string +} + +type locateDocMatch struct { + AnchorBlockID string + ParentBlockID string + Blocks []locateDocBlock +} + +type locateDocResult struct { + MatchCount int + Matches []locateDocMatch +} + +type commentReplyElementInput struct { + Type string `json:"type"` + Text string `json:"text"` + MentionUser string `json:"mention_user"` + Link string `json:"link"` +} + +type commentMode string + +const ( + commentModeLocal commentMode = "local" + commentModeFull commentMode = "full" +) + +var DriveAddComment = common.Shortcut{ + Service: "drive", + Command: "+add-comment", + Description: "Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx)", + Risk: "write", + Scopes: []string{ + "docx:document:readonly", + "docs:document.comment:create", + "docs:document.comment:write_only", + }, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL/token, or wiki URL that resolves to doc/docx", Required: true}, + {Name: "content", Desc: "reply_elements JSON string", Required: true}, + {Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"}, + {Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"}, + {Name: "block-id", Desc: "anchor block ID (skip MCP locate-doc if already known)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + docRef, err := parseCommentDocRef(runtime.Str("doc")) + if err != nil { + return err + } + + if _, err := parseCommentReplyElements(runtime.Str("content")); err != nil { + return err + } + + selection := runtime.Str("selection-with-ellipsis") + blockID := strings.TrimSpace(runtime.Str("block-id")) + if strings.TrimSpace(selection) != "" && blockID != "" { + return output.ErrValidation("--selection-with-ellipsis and --block-id are mutually exclusive") + } + if runtime.Bool("full-comment") && (strings.TrimSpace(selection) != "" || blockID != "") { + return output.ErrValidation("--full-comment cannot be used with --selection-with-ellipsis or --block-id") + } + + mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) + if mode == commentModeLocal && docRef.Kind == "doc" { + return output.ErrValidation("local comments only support docx documents; use --full-comment or omit location flags for a whole-document comment") + } + + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + docRef, _ := parseCommentDocRef(runtime.Str("doc")) + replyElements, _ := parseCommentReplyElements(runtime.Str("content")) + selection := runtime.Str("selection-with-ellipsis") + blockID := strings.TrimSpace(runtime.Str("block-id")) + mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) + + targetToken, targetFileType, resolvedBy := dryRunResolvedCommentTarget(docRef, mode) + + createPath := "/open-apis/drive/v1/files/:file_token/new_comments" + commentBody := buildCommentCreateV2Request(targetFileType, "", replyElements) + if mode == commentModeLocal { + commentBody = buildCommentCreateV2Request(targetFileType, anchorBlockIDForDryRun(blockID), replyElements) + } + + mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand) + + dry := common.NewDryRunAPI() + switch { + case mode == commentModeFull && resolvedBy == "wiki": + dry.Desc("2-step orchestration: resolve wiki -> create full comment") + case mode == commentModeFull: + dry.Desc("1-step request: create full comment") + case resolvedBy == "wiki" && strings.TrimSpace(selection) != "": + dry.Desc("3-step orchestration: resolve wiki -> locate block -> create local comment") + case resolvedBy == "wiki": + dry.Desc("2-step orchestration: resolve wiki -> create local comment") + case strings.TrimSpace(selection) != "": + dry.Desc("2-step orchestration: locate block -> create local comment") + default: + dry.Desc("1-step request: create local comment with explicit block ID") + } + + if resolvedBy == "wiki" { + dry.GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to target document"). + Params(map[string]interface{}{"token": docRef.Token}) + } + + if mode == commentModeLocal && strings.TrimSpace(selection) != "" { + step := "[1]" + if resolvedBy == "wiki" { + step = "[2]" + } + mcpArgs := map[string]interface{}{ + "doc_id": dryRunLocateDocRef(docRef), + "limit": defaultLocateDocLimit, + "selection_with_ellipsis": selection, + } + dry.POST(mcpEndpoint). + Desc(step+" MCP tool: locate-doc"). + Body(map[string]interface{}{ + "method": "tools/call", + "params": map[string]interface{}{ + "name": "locate-doc", + "arguments": mcpArgs, + }, + }). + Set("mcp_tool", "locate-doc"). + Set("args", mcpArgs) + } + + step := "[1]" + createDesc := "Create full comment" + if mode == commentModeLocal { + createDesc = "Create local comment" + step = "[2]" + if resolvedBy == "wiki" && strings.TrimSpace(selection) != "" { + step = "[3]" + } else if resolvedBy == "wiki" || strings.TrimSpace(selection) != "" { + step = "[2]" + } else { + step = "[1]" + } + } else if resolvedBy == "wiki" { + step = "[2]" + } + + return dry.POST(createPath). + Desc(step+" "+createDesc). + Body(commentBody). + Set("file_token", targetToken) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + selection := runtime.Str("selection-with-ellipsis") + blockID := strings.TrimSpace(runtime.Str("block-id")) + mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) + + target, err := resolveCommentTarget(ctx, runtime, runtime.Str("doc"), mode) + if err != nil { + return err + } + + replyElements, err := parseCommentReplyElements(runtime.Str("content")) + if err != nil { + return err + } + + var locateResult locateDocResult + selectedMatch := 0 + if mode == commentModeLocal && blockID == "" { + _, locateResult, err = locateDocumentSelection(runtime, target, selection, defaultLocateDocLimit) + if err != nil { + return err + } + + match, idx, err := selectLocateMatch(locateResult) + if err != nil { + return err + } + blockID = match.AnchorBlockID + if strings.TrimSpace(blockID) == "" { + return output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id") + } + selectedMatch = idx + fmt.Fprintf(runtime.IO().ErrOut, "Locate-doc matched %d block(s); using match #%d (%s)\n", len(locateResult.Matches), idx, blockID) + } else if mode == commentModeLocal { + fmt.Fprintf(runtime.IO().ErrOut, "Using explicit block ID: %s\n", blockID) + } + + requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken)) + requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements) + if mode == commentModeLocal { + requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements) + } + + if mode == commentModeLocal { + fmt.Fprintf(runtime.IO().ErrOut, "Creating local comment in %s\n", common.MaskToken(target.FileToken)) + } else { + fmt.Fprintf(runtime.IO().ErrOut, "Creating full comment in %s\n", common.MaskToken(target.FileToken)) + } + + data, err := runtime.CallAPI( + "POST", + requestPath, + nil, + requestBody, + ) + if err != nil { + return err + } + + out := map[string]interface{}{ + "comment_id": data["comment_id"], + "doc_id": target.DocID, + "file_token": target.FileToken, + "file_type": target.FileType, + "resolved_by": target.ResolvedBy, + "comment_mode": string(mode), + } + if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil { + out["created_at"] = createdAt + } + if target.WikiToken != "" { + out["wiki_token"] = target.WikiToken + } + if mode == commentModeLocal { + out["anchor_block_id"] = blockID + out["selection_source"] = "block_id" + if strings.TrimSpace(selection) != "" { + out["selection_source"] = "locate-doc" + out["selection_with_ellipsis"] = selection + out["match_count"] = locateResult.MatchCount + out["match_index"] = selectedMatch + } + } else if isWhole, ok := data["is_whole"]; ok { + out["is_whole"] = isWhole + } + + runtime.Out(out, nil) + return nil + }, +} + +func resolveCommentMode(explicitFullComment bool, selection, blockID string) commentMode { + if explicitFullComment { + return commentModeFull + } + if strings.TrimSpace(selection) == "" && strings.TrimSpace(blockID) == "" { + return commentModeFull + } + return commentModeLocal +} + +func parseCommentDocRef(input string) (commentDocRef, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return commentDocRef{}, output.ErrValidation("--doc cannot be empty") + } + + if token, ok := extractURLToken(raw, "/wiki/"); ok { + return commentDocRef{Kind: "wiki", Token: token}, nil + } + if token, ok := extractURLToken(raw, "/docx/"); ok { + return commentDocRef{Kind: "docx", Token: token}, nil + } + if token, ok := extractURLToken(raw, "/doc/"); ok { + return commentDocRef{Kind: "doc", Token: token}, nil + } + if strings.Contains(raw, "://") { + return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx URL, a docx token, or a wiki URL that resolves to doc/docx", raw) + } + if strings.ContainsAny(raw, "/?#") { + return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw) + } + + return commentDocRef{Kind: "docx", Token: raw}, nil +} + +func dryRunResolvedCommentTarget(docRef commentDocRef, mode commentMode) (token, fileType, resolvedBy string) { + switch docRef.Kind { + case "docx": + return docRef.Token, "docx", "docx" + case "doc": + return docRef.Token, "doc", "doc" + case "wiki": + if mode == commentModeFull { + return "", "", "wiki" + } + return "", "docx", "wiki" + default: + return "", "docx", "docx" + } +} + +func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, input string, mode commentMode) (resolvedCommentTarget, error) { + docRef, err := parseCommentDocRef(input) + if err != nil { + return resolvedCommentTarget{}, err + } + + if docRef.Kind == "docx" || docRef.Kind == "doc" { + if mode == commentModeLocal && docRef.Kind != "docx" { + return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx documents") + } + return resolvedCommentTarget{ + DocID: docRef.Token, + FileToken: docRef.Token, + FileType: docRef.Kind, + ResolvedBy: docRef.Kind, + }, nil + } + + fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token)) + data, err := runtime.CallAPI( + "GET", + "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{"token": docRef.Token}, + nil, + ) + if err != nil { + return resolvedCommentTarget{}, err + } + + node := common.GetMap(data, "node") + objType := common.GetString(node, "obj_type") + objToken := common.GetString(node, "obj_token") + if objType == "" || objToken == "" { + return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data") + } + if mode == commentModeLocal && objType != "docx" { + return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments currently only support docx documents", objType) + } + if mode == commentModeFull && objType != "docx" && objType != "doc" { + return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but full comments only support doc/docx documents", objType) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken)) + return resolvedCommentTarget{ + DocID: objToken, + FileToken: objToken, + FileType: objType, + ResolvedBy: "wiki", + WikiToken: docRef.Token, + }, nil +} + +func locateDocumentSelection(runtime *common.RuntimeContext, target resolvedCommentTarget, selection string, limit int) (map[string]interface{}, locateDocResult, error) { + args := map[string]interface{}{ + "doc_id": target.DocID, + "limit": limit, + "selection_with_ellipsis": selection, + } + + result, err := common.CallMCPTool(runtime, "locate-doc", args) + if err != nil { + return nil, locateDocResult{}, err + } + + return result, parseLocateDocResult(result), nil +} + +func parseLocateDocResult(result map[string]interface{}) locateDocResult { + rawMatches := common.GetSlice(result, "matches") + locate := locateDocResult{ + MatchCount: int(common.GetFloat(result, "match_count")), + } + + for _, item := range rawMatches { + matchMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + match := locateDocMatch{ + AnchorBlockID: common.GetString(matchMap, "anchor_block_id"), + ParentBlockID: common.GetString(matchMap, "parent_block_id"), + } + for _, blockItem := range common.GetSlice(matchMap, "blocks") { + blockMap, ok := blockItem.(map[string]interface{}) + if !ok { + continue + } + match.Blocks = append(match.Blocks, locateDocBlock{ + BlockID: common.GetString(blockMap, "block_id"), + RawMarkdown: common.GetString(blockMap, "raw_markdown"), + }) + } + if match.AnchorBlockID == "" && len(match.Blocks) > 0 { + match.AnchorBlockID = match.Blocks[0].BlockID + } + locate.Matches = append(locate.Matches, match) + } + + if locate.MatchCount == 0 { + locate.MatchCount = len(locate.Matches) + } + return locate +} + +func selectLocateMatch(result locateDocResult) (locateDocMatch, int, error) { + if len(result.Matches) == 0 { + return locateDocMatch{}, 0, output.ErrValidation("locate-doc did not find any matching block") + } + + if len(result.Matches) > 1 { + return locateDocMatch{}, 0, output.ErrWithHint( + output.ExitValidation, + "ambiguous_match", + fmt.Sprintf("locate-doc matched %d blocks:\n%s", len(result.Matches), formatLocateCandidates(result.Matches)), + "narrow --selection-with-ellipsis until only one block matches", + ) + } + + return result.Matches[0], 1, nil +} + +func formatLocateCandidates(matches []locateDocMatch) string { + lines := make([]string, 0, len(matches)) + for i, match := range matches { + lines = append(lines, fmt.Sprintf("%d. anchor_block_id=%s", i+1, match.AnchorBlockID)) + } + return strings.Join(lines, "\n") +} + +func summarizeLocateMatch(match locateDocMatch) string { + if len(match.Blocks) == 0 { + return "" + } + + parts := make([]string, 0, len(match.Blocks)) + for _, block := range match.Blocks { + snippet := strings.TrimSpace(block.RawMarkdown) + if snippet == "" { + continue + } + snippet = strings.ReplaceAll(snippet, "\n", " ") + parts = append(parts, snippet) + } + return common.TruncateStr(strings.Join(parts, " | "), 120) +} + +func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { + if strings.TrimSpace(raw) == "" { + return nil, output.ErrValidation("--content cannot be empty") + } + + var inputs []commentReplyElementInput + if err := json.Unmarshal([]byte(raw), &inputs); err != nil { + return nil, output.ErrValidation("--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err) + } + if len(inputs) == 0 { + return nil, output.ErrValidation("--content must contain at least one reply element") + } + + replyElements := make([]map[string]interface{}, 0, len(inputs)) + for i, input := range inputs { + index := i + 1 + elementType := strings.TrimSpace(input.Type) + switch elementType { + case "text": + if strings.TrimSpace(input.Text) == "" { + return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index) + } + if utf8.RuneCountInString(input.Text) > 1000 { + return nil, output.ErrValidation("--content element #%d text exceeds 1000 characters", index) + } + replyElements = append(replyElements, map[string]interface{}{ + "type": "text", + "text": input.Text, + }) + case "mention_user": + mentionUser := firstNonEmptyString(input.MentionUser, input.Text) + if mentionUser == "" { + return nil, output.ErrValidation("--content element #%d type=mention_user requires text or mention_user", index) + } + replyElements = append(replyElements, map[string]interface{}{ + "type": "mention_user", + "mention_user": mentionUser, + }) + case "link": + link := firstNonEmptyString(input.Link, input.Text) + if link == "" { + return nil, output.ErrValidation("--content element #%d type=link requires text or link", index) + } + replyElements = append(replyElements, map[string]interface{}{ + "type": "link", + "link": link, + }) + default: + return nil, output.ErrValidation("--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type) + } + } + + return replyElements, nil +} + +func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}) map[string]interface{} { + body := map[string]interface{}{ + "file_type": fileType, + "reply_elements": replyElements, + } + if strings.TrimSpace(blockID) != "" { + body["anchor"] = map[string]interface{}{ + "block_id": blockID, + } + } + return body +} + +func anchorBlockIDForDryRun(blockID string) string { + if strings.TrimSpace(blockID) != "" { + return strings.TrimSpace(blockID) + } + return "" +} + +func dryRunLocateDocRef(docRef commentDocRef) string { + if docRef.Kind == "wiki" { + return "" + } + return docRef.Token +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func firstPresentValue(m map[string]interface{}, keys ...string) interface{} { + for _, key := range keys { + if value, ok := m[key]; ok && value != nil { + return value + } + } + return nil +} + +func extractURLToken(raw, marker string) (string, bool) { + idx := strings.Index(raw, marker) + if idx < 0 { + return "", false + } + token := raw[idx+len(marker):] + if end := strings.IndexAny(token, "/?#"); end >= 0 { + token = token[:end] + } + token = strings.TrimSpace(token) + if token == "" { + return "", false + } + return token, true +} diff --git a/shortcuts/drive/drive_add_comment_test.go b/shortcuts/drive/drive_add_comment_test.go new file mode 100644 index 00000000..ae8b02fd --- /dev/null +++ b/shortcuts/drive/drive_add_comment_test.go @@ -0,0 +1,302 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "strings" + "testing" +) + +func TestParseCommentDocRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantKind string + wantToken string + wantErr string + }{ + { + name: "docx url", + input: "https://example.larksuite.com/docx/xxxxxx?from=wiki", + wantKind: "docx", + wantToken: "xxxxxx", + }, + { + name: "wiki url", + input: "https://example.larksuite.com/wiki/xxxxxx", + wantKind: "wiki", + wantToken: "xxxxxx", + }, + { + name: "raw token treated as docx", + input: "xxxxxx", + wantKind: "docx", + wantToken: "xxxxxx", + }, + { + name: "old doc url", + input: "https://example.larksuite.com/doc/xxxxxx", + wantKind: "doc", + wantToken: "xxxxxx", + }, + { + name: "unsupported url", + input: "https://example.com/not-a-doc", + wantErr: "unsupported --doc input", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parseCommentDocRef(tt.input) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Kind != tt.wantKind { + t.Fatalf("kind mismatch: want %q, got %q", tt.wantKind, got.Kind) + } + if got.Token != tt.wantToken { + t.Fatalf("token mismatch: want %q, got %q", tt.wantToken, got.Token) + } + }) + } +} + +func TestResolveCommentMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + explicitFull bool + selection string + blockID string + want commentMode + }{ + { + name: "explicit full comment", + explicitFull: true, + want: commentModeFull, + }, + { + name: "auto full comment without anchor", + explicitFull: false, + want: commentModeFull, + }, + { + name: "selection means local comment", + selection: "流程", + want: commentModeLocal, + }, + { + name: "block id means local comment", + blockID: "blk_123", + want: commentModeLocal, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := resolveCommentMode(tt.explicitFull, tt.selection, tt.blockID) + if got != tt.want { + t.Fatalf("mode mismatch: want %q, got %q", tt.want, got) + } + }) + } +} + +func TestSelectLocateMatch(t *testing.T) { + t.Parallel() + + result := locateDocResult{ + MatchCount: 2, + Matches: []locateDocMatch{ + { + AnchorBlockID: "blk_1", + Blocks: []locateDocBlock{ + {BlockID: "blk_1", RawMarkdown: "流程\n"}, + }, + }, + { + AnchorBlockID: "blk_2", + Blocks: []locateDocBlock{ + {BlockID: "blk_2", RawMarkdown: "流程图\n"}, + }, + }, + }, + } + + _, _, err := selectLocateMatch(result) + if err == nil || !strings.Contains(err.Error(), "matched 2 blocks") { + t.Fatalf("expected ambiguous match error, got %v", err) + } + if strings.Contains(err.Error(), "流程") || strings.Contains(err.Error(), "流程图") { + t.Fatalf("ambiguous match error should not leak locate-doc snippets: %v", err) + } + if !strings.Contains(err.Error(), "anchor_block_id=blk_1") || !strings.Contains(err.Error(), "anchor_block_id=blk_2") { + t.Fatalf("ambiguous match error should keep anchor block identifiers: %v", err) + } +} + +func TestParseLocateDocResultFallsBackToFirstBlock(t *testing.T) { + t.Parallel() + + got := parseLocateDocResult(map[string]interface{}{ + "match_count": float64(1), + "matches": []interface{}{ + map[string]interface{}{ + "blocks": []interface{}{ + map[string]interface{}{ + "block_id": "blk_anchor", + "raw_markdown": "流程\n", + }, + }, + }, + }, + }) + + if len(got.Matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(got.Matches)) + } + if got.Matches[0].AnchorBlockID != "blk_anchor" { + t.Fatalf("expected fallback anchor block, got %q", got.Matches[0].AnchorBlockID) + } +} + +func TestParseCommentReplyElements(t *testing.T) { + t.Parallel() + + got, err := parseCommentReplyElements(`[{"type":"text","text":"文本信息"},{"type":"mention_user","text":"ou_123"},{"type":"link","text":"https://example.com"}]`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 3 { + t.Fatalf("expected 3 reply elements, got %d", len(got)) + } + if got[0]["type"] != "text" || got[0]["text"] != "文本信息" { + t.Fatalf("unexpected text reply element: %#v", got[0]) + } + if got[1]["type"] != "mention_user" || got[1]["mention_user"] != "ou_123" { + t.Fatalf("unexpected mention_user reply element: %#v", got[1]) + } + if got[2]["type"] != "link" || got[2]["link"] != "https://example.com" { + t.Fatalf("unexpected link reply element: %#v", got[2]) + } +} + +func TestParseCommentReplyElementsInvalid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "invalid json", + input: `[{"type":"text","text":"x"}`, + wantErr: "--content is not valid JSON", + }, + { + name: "empty array", + input: `[]`, + wantErr: "must contain at least one reply element", + }, + { + name: "unsupported type", + input: `[{"type":"image","text":"x"}]`, + wantErr: "unsupported type", + }, + { + name: "mention missing value", + input: `[{"type":"mention_user","text":""}]`, + wantErr: "requires text or mention_user", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if _, err := parseCommentReplyElements(tt.input); err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +func TestBuildCommentCreateV2RequestFull(t *testing.T) { + t.Parallel() + + replyElements := []map[string]interface{}{ + { + "type": "text", + "text": "全文评论", + }, + } + got := buildCommentCreateV2Request("docx", "", replyElements) + + if got["file_type"] != "docx" { + t.Fatalf("expected file_type docx, got %#v", got["file_type"]) + } + if _, ok := got["anchor"]; ok { + t.Fatalf("expected no anchor for full comment, got %#v", got["anchor"]) + } + + gotReplyElements, ok := got["reply_elements"].([]map[string]interface{}) + if !ok || len(gotReplyElements) != 1 { + t.Fatalf("expected one reply element, got %#v", got["reply_elements"]) + } + if gotReplyElements[0]["type"] != "text" { + t.Fatalf("expected text element, got %#v", gotReplyElements[0]["type"]) + } + if gotReplyElements[0]["text"] != "全文评论" { + t.Fatalf("expected text %q, got %#v", "全文评论", gotReplyElements[0]["text"]) + } +} + +func TestBuildCommentCreateV2RequestLocal(t *testing.T) { + t.Parallel() + + replyElements := []map[string]interface{}{ + { + "type": "text", + "text": "评论内容", + }, + } + got := buildCommentCreateV2Request("docx", "blk_123", replyElements) + + if got["file_type"] != "docx" { + t.Fatalf("expected file_type docx, got %#v", got["file_type"]) + } + anchor, ok := got["anchor"].(map[string]interface{}) + if !ok { + t.Fatalf("expected anchor map, got %#v", got["anchor"]) + } + if anchor["block_id"] != "blk_123" { + t.Fatalf("expected block_id blk_123, got %#v", anchor["block_id"]) + } + + gotReplyElements, ok := got["reply_elements"].([]map[string]interface{}) + if !ok || len(gotReplyElements) != 1 { + t.Fatalf("expected one reply element, got %#v", got["reply_elements"]) + } + if gotReplyElements[0]["type"] != "text" || gotReplyElements[0]["text"] != "评论内容" { + t.Fatalf("unexpected reply element: %#v", gotReplyElements[0]) + } +} diff --git a/shortcuts/drive/drive_download.go b/shortcuts/drive/drive_download.go new file mode 100644 index 00000000..578c415f --- /dev/null +++ b/shortcuts/drive/drive_download.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + // validate import used below + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var DriveDownload = common.Shortcut{ + Service: "drive", + Command: "+download", + Description: "Download a file from Drive to local", + Risk: "read", + Scopes: []string{"drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file-token", Desc: "file token", Required: true}, + {Name: "output", Desc: "local save path"}, + {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + fileToken := runtime.Str("file-token") + outputPath := runtime.Str("output") + if outputPath == "" { + outputPath = fileToken + } + return common.NewDryRunAPI(). + GET("/open-apis/drive/v1/files/:file_token/download"). + Set("file_token", fileToken).Set("output", outputPath) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + fileToken := runtime.Str("file-token") + outputPath := runtime.Str("output") + overwrite := runtime.Bool("overwrite") + + if err := validate.ResourceName(fileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + + if outputPath == "" { + outputPath = fileToken + } + safePath, err := validate.SafeOutputPath(outputPath) + if err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + if err := common.EnsureWritableFile(safePath, overwrite); err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken)) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), + }, larkcore.WithFileDownload()) + if err != nil { + return output.ErrNetwork("download failed: %s", err) + } + + if apiResp.StatusCode >= 400 { + return output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)) + } + + os.MkdirAll(filepath.Dir(safePath), 0755) + + if err := validate.AtomicWrite(safePath, apiResp.RawBody, 0644); err != nil { + return output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) + } + + runtime.Out(map[string]interface{}{ + "saved_path": safePath, + "size_bytes": len(apiResp.RawBody), + }, nil) + return nil + }, +} diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go new file mode 100644 index 00000000..66750d58 --- /dev/null +++ b/shortcuts/drive/drive_io_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bytes" + "net/http" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func driveTestConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + } +} + +func mountAndRunDrive(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "drive"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func withDriveWorkingDir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error: %v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("restore cwd error: %v", err) + } + }) +} + +func registerDriveBotTokenStub(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token", "expire": 7200, + }, + }) +} + +func TestDriveUploadRejectsLargeFile(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + fh, err := os.Create("large.bin") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil { + t.Fatalf("Truncate() error: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + err = mountAndRunDrive(t, DriveUpload, []string{ + "+upload", + "--file", "large.bin", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected size limit error, got nil") + } + if !strings.Contains(err.Error(), "exceeds 20MB limit") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveDownloadRejectsOverwriteWithoutFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.WriteFile("existing.bin", []byte("old"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveDownload, []string{ + "+download", + "--file-token", "file_123", + "--output", "existing.bin", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected overwrite protection error, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/file_123/download", + Status: 200, + Body: []byte("new"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.WriteFile("existing.bin", []byte("old"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveDownload, []string{ + "+download", + "--file-token", "file_123", + "--output", "existing.bin", + "--overwrite", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile("existing.bin") + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "new" { + t.Fatalf("downloaded file content = %q, want %q", string(data), "new") + } + if !strings.Contains(stdout.String(), "existing.bin") { + t.Fatalf("stdout missing saved path: %s", stdout.String()) + } +} diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go new file mode 100644 index 00000000..965e8909 --- /dev/null +++ b/shortcuts/drive/drive_upload.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const maxDriveUploadFileSize = 20 * 1024 * 1024 // 20MB + +var DriveUpload = common.Shortcut{ + Service: "drive", + Command: "+upload", + Description: "Upload a local file to Drive", + Risk: "write", + Scopes: []string{"drive:file:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file", Desc: "local file path (max 20MB)", Required: true}, + {Name: "folder-token", Desc: "target folder token (default: root)"}, + {Name: "name", Desc: "uploaded file name (default: local file name)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + filePath := runtime.Str("file") + folderToken := runtime.Str("folder-token") + name := runtime.Str("name") + fileName := name + if fileName == "" { + fileName = filepath.Base(filePath) + } + return common.NewDryRunAPI(). + Desc("multipart/form-data upload"). + POST("/open-apis/drive/v1/files/upload_all"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": "explorer", + "parent_node": folderToken, + "file": "@" + filePath, + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + folderToken := runtime.Str("folder-token") + name := runtime.Str("name") + + safeFilePath, err := validate.SafeInputPath(filePath) + if err != nil { + return output.ErrValidation("unsafe file path: %s", err) + } + filePath = safeFilePath + + fileName := name + if fileName == "" { + fileName = filepath.Base(filePath) + } + + info, err := os.Stat(filePath) + if err != nil { + return output.ErrValidation("cannot read file: %s", err) + } + fileSize := info.Size() + if fileSize > maxDriveUploadFileSize { + return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileSize)/1024/1024) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s)\n", fileName, common.FormatSize(fileSize)) + + // Use SDK multipart upload + fileToken, err := uploadFileToDrive(ctx, runtime, filePath, fileName, folderToken, fileSize) + if err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "size": fileSize, + }, nil) + return nil + }, +} + +func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, folderToken string, fileSize int64) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + // Build SDK Formdata + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", "explorer") + fd.AddField("parent_node", folderToken) + fd.AddField("size", fmt.Sprintf("%d", fileSize)) + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/files/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return "", err + } + return "", output.ErrNetwork("upload failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) + } + + if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { + msg, _ := result["msg"].(string) + return "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + if fileToken == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + } + return fileToken, nil +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go new file mode 100644 index 00000000..fb12d6c6 --- /dev/null +++ b/shortcuts/drive/shortcuts.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all drive shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + DriveUpload, + DriveDownload, + DriveAddComment, + } +} diff --git a/shortcuts/event/filter.go b/shortcuts/event/filter.go new file mode 100644 index 00000000..657ae289 --- /dev/null +++ b/shortcuts/event/filter.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "regexp" + "sort" + "strings" +) + +// EventFilter decides whether an event should be processed. +type EventFilter interface { + Allow(eventType string) bool +} + +// FilterChain combines multiple filters with AND logic. +type FilterChain struct { + filters []EventFilter +} + +// NewFilterChain creates a filter chain. Nil filters are skipped. +func NewFilterChain(filters ...EventFilter) *FilterChain { + var valid []EventFilter + for _, f := range filters { + if f != nil { + valid = append(valid, f) + } + } + return &FilterChain{filters: valid} +} + +// Allow returns true when all filters pass. An empty chain allows all events. +func (c *FilterChain) Allow(eventType string) bool { + if c == nil { + return true + } + for _, f := range c.filters { + if !f.Allow(eventType) { + return false + } + } + return true +} + +// EventTypeFilter filters by an event type whitelist. +type EventTypeFilter struct { + allowed map[string]bool +} + +// NewEventTypeFilter creates a whitelist filter from a comma-separated string. +// Returns nil for empty input (meaning no filtering). +func NewEventTypeFilter(commaSeparated string) *EventTypeFilter { + if commaSeparated == "" { + return nil + } + allowed := make(map[string]bool) + for _, t := range strings.Split(commaSeparated, ",") { + t = strings.TrimSpace(t) + if t != "" { + allowed[t] = true + } + } + if len(allowed) == 0 { + return nil + } + return &EventTypeFilter{allowed: allowed} +} + +func (f *EventTypeFilter) Allow(eventType string) bool { + return f.allowed[eventType] +} + +// Types returns the whitelisted event types. +func (f *EventTypeFilter) Types() []string { + types := make([]string, 0, len(f.allowed)) + for t := range f.allowed { + types = append(types, t) + } + sort.Strings(types) + return types +} + +// RegexFilter filters event types by a regular expression. +type RegexFilter struct { + re *regexp.Regexp +} + +// NewRegexFilter compiles a regex and creates a filter. Returns nil, nil for empty input. +func NewRegexFilter(pattern string) (*RegexFilter, error) { + if pattern == "" { + return nil, nil + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + return &RegexFilter{re: re}, nil +} + +func (f *RegexFilter) Allow(eventType string) bool { + return f.re.MatchString(eventType) +} + +func (f *RegexFilter) String() string { + return f.re.String() +} diff --git a/shortcuts/event/helpers.go b/shortcuts/event/helpers.go new file mode 100644 index 00000000..81031b83 --- /dev/null +++ b/shortcuts/event/helpers.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +// ── Shared helpers for IM event processors ────────────────────────────────── +// These functions are used across multiple processor files to extract common +// fields from Lark event payloads (operator_id, user_ids, base compact fields). + +// openID extracts open_id from a nested {"open_id":"ou_xxx"} structure. +// Lark events represent user IDs as objects; this unwraps the outer layer. +func openID(v interface{}) string { + m, ok := v.(map[string]interface{}) + if !ok { + return "" + } + s, _ := m["open_id"].(string) + return s +} + +// extractUserIDs extracts open_ids from a users array: +// [{"user_id":{"open_id":"ou_xxx"},"name":"..."},...] +func extractUserIDs(users []interface{}) []string { + var ids []string + for _, u := range users { + um, ok := u.(map[string]interface{}) + if !ok { + continue + } + if id := openID(um["user_id"]); id != "" { + ids = append(ids, id) + } + } + return ids +} + +// compactBase builds the common compact output fields shared by all IM event processors. +// Every compact output includes: type (event_type), event_id, and timestamp (header create_time). +func compactBase(raw *RawEvent) map[string]interface{} { + out := map[string]interface{}{ + "type": raw.Header.EventType, + } + if raw.Header.EventID != "" { + out["event_id"] = raw.Header.EventID + } + if raw.Header.CreateTime != "" { + out["timestamp"] = raw.Header.CreateTime + } + return out +} diff --git a/shortcuts/event/pipeline.go b/shortcuts/event/pipeline.go new file mode 100644 index 00000000..28d552ff --- /dev/null +++ b/shortcuts/event/pipeline.go @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sync" + "sync/atomic" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" +) + +const dedupTTL = 5 * time.Minute + +// PipelineConfig configures the event processing pipeline. +type PipelineConfig struct { + Mode TransformMode // determined by --compact flag + JsonFlag bool // --json: pretty JSON instead of NDJSON + OutputDir string // --output-dir: write events to files + Quiet bool // --quiet: suppress stderr status messages + Router *EventRouter // --route: regex-based output routing +} + +// EventPipeline chains filter → dedup → transform → emit. +type EventPipeline struct { + registry *ProcessorRegistry + filters *FilterChain + config PipelineConfig + eventCount atomic.Int64 + seen sync.Map // key → time.Time (first-seen timestamp) + out io.Writer + errOut io.Writer +} + +// NewEventPipeline builds an event processing pipeline. +func NewEventPipeline( + registry *ProcessorRegistry, + filters *FilterChain, + config PipelineConfig, + out, errOut io.Writer, +) *EventPipeline { + return &EventPipeline{ + registry: registry, + filters: filters, + config: config, + out: out, + errOut: errOut, + } +} + +// EnsureDirs creates all configured output directories once at startup. +func (p *EventPipeline) EnsureDirs() error { + if p.config.OutputDir != "" { + if err := os.MkdirAll(p.config.OutputDir, 0700); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + } + if p.config.Router != nil { + for _, route := range p.config.Router.routes { + if err := os.MkdirAll(route.dir, 0700); err != nil { + return fmt.Errorf("create route dir %s: %w", route.dir, err) + } + } + } + return nil +} + +// EventCount returns the number of processed events. +func (p *EventPipeline) EventCount() int64 { + return p.eventCount.Load() +} + +func (p *EventPipeline) infof(format string, args ...interface{}) { + if !p.config.Quiet { + fmt.Fprintf(p.errOut, format+"\n", args...) + } +} + +// isDuplicate returns true if key was seen within dedupTTL. +func (p *EventPipeline) isDuplicate(key string) bool { + now := time.Now() + if v, loaded := p.seen.LoadOrStore(key, now); loaded { + if ts, ok := v.(time.Time); ok && now.Sub(ts) < dedupTTL { + return true + } + p.seen.Store(key, now) + } + return false +} + +func (p *EventPipeline) cleanupSeen(now time.Time) { + p.seen.Range(func(k, v any) bool { + if ts, ok := v.(time.Time); ok && now.Sub(ts) >= dedupTTL { + p.seen.Delete(k) + } + return true + }) +} + +// Process is the pipeline entry point, called by the WebSocket callback. +func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) { + eventType := raw.Header.EventType + + // 1. Filter + if !p.filters.Allow(eventType) { + return + } + + // 2. Lookup processor + processor := p.registry.Lookup(eventType) + + // 3. Dedup + if key := processor.DeduplicateKey(raw); key != "" && p.isDuplicate(key) { + p.infof("%s[dedup]%s %s (key=%s)", output.Dim, output.Reset, eventType, key) + return + } + + n := p.eventCount.Add(1) + if n%100 == 0 { + p.cleanupSeen(time.Now()) + } + + // 4. Transform — processor returns the final serializable value + data := processor.Transform(ctx, raw, p.config.Mode) + + // 5. Output routing (framework-controlled) + // 5a. Route-based output — matched events go to route dirs + if p.config.Router != nil { + if dirs := p.config.Router.Match(eventType); len(dirs) > 0 { + for _, dir := range dirs { + p.writeAndLog(dir, n, eventType, data, raw.Header) + } + return + } + } + + // 5b. --output-dir + if p.config.OutputDir != "" { + p.writeAndLog(p.config.OutputDir, n, eventType, data, raw.Header) + return + } + + // 5c. Stdout + if p.config.JsonFlag { + output.PrintJson(p.out, data) + } else { + output.PrintNdjson(p.out, data) + } + p.infof("%s[%d]%s %s", output.Dim, n, output.Reset, eventType) +} + +// writeAndLog writes an event to a directory and logs the result. +func (p *EventPipeline) writeAndLog(dir string, n int64, eventType string, data interface{}, header larkevent.EventHeader) { + fp, err := writeEventFile(dir, data, header) + if err != nil { + output.PrintError(p.errOut, fmt.Sprintf("write failed (%s): %v", dir, err)) + } else { + p.infof("%s[%d]%s %s → %s", output.Dim, n, output.Reset, eventType, fp) + } +} + +var filenameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func writeEventFile(dir string, data interface{}, header larkevent.EventHeader) (string, error) { + eventID := header.EventID + if eventID == "" { + eventID = "unknown" + } + ts := header.CreateTime + if ts == "" { + ts = fmt.Sprintf("%d", os.Getpid()) + } + + safeName := filenameSanitizer.ReplaceAllString(header.EventType, "_") + filename := fmt.Sprintf("%s_%s_%s.json", safeName, eventID, ts) + outPath := filepath.Join(dir, filename) + + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", err + } + + if err := validate.AtomicWrite(outPath, append(jsonData, '\n'), 0600); err != nil { + return "", err + } + + return outPath, nil +} diff --git a/shortcuts/event/processor.go b/shortcuts/event/processor.go new file mode 100644 index 00000000..d2d51e59 --- /dev/null +++ b/shortcuts/event/processor.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "time" + + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" +) + +// TransformMode defines the event transformation mode. +type TransformMode int + +const ( + // TransformRaw passes through with minimal processing. + TransformRaw TransformMode = iota + // TransformCompact extracts core fields, suitable for AI agent consumption. + TransformCompact +) + +// WindowConfig configures event windowing strategy (not implemented yet). +// Zero value means disabled. +type WindowConfig struct { + Duration time.Duration + GroupBy string +} + +// RawEvent is the strongly-typed V2 event envelope. +// Parsed directly from event.Body JSON bytes. +type RawEvent struct { + Schema string `json:"schema"` + Header larkevent.EventHeader `json:"header"` + Event json.RawMessage `json:"event"` +} + +// EventProcessor defines the processing strategy for each event type. +// +// Each processor implements its own Transform logic supporting Raw/Compact modes. +// The framework decides which mode to pass based on CLI flags; the processor +// decides the output format for that mode. +// +// Raw mode: return raw (the complete *RawEvent) to preserve the full original event. +// Compact mode: return a flat map[string]interface{} ready for JSON serialization, +// including semantic fields like "type", "id", "from", "to" plus domain-specific fields. +type EventProcessor interface { + // EventType returns the event type handled, e.g. "im.message.receive_v1". + // The fallback processor returns an empty string. + EventType() string + + // Transform converts raw event data to the target format. + // The returned value is serialized directly to JSON by the pipeline. + Transform(ctx context.Context, raw *RawEvent, mode TransformMode) interface{} + + // DeduplicateKey returns a deduplication key. Empty string means no dedup. + DeduplicateKey(raw *RawEvent) string + + // WindowStrategy returns window configuration. Zero value means disabled. + WindowStrategy() WindowConfig +} diff --git a/shortcuts/event/processor_generic.go b/shortcuts/event/processor_generic.go new file mode 100644 index 00000000..793a79e0 --- /dev/null +++ b/shortcuts/event/processor_generic.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" +) + +// GenericProcessor is the fallback for unregistered event types. +// Compact mode parses the event payload as a map; Raw mode passes through raw.Event. +type GenericProcessor struct{} + +func (p *GenericProcessor) EventType() string { return "" } + +func (p *GenericProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + // Compact: parse event as flat map, inject envelope metadata so AI + // can always identify the event type regardless of which processor ran. + var eventMap map[string]interface{} + if err := json.Unmarshal(raw.Event, &eventMap); err != nil { + return raw + } + eventMap["type"] = raw.Header.EventType + if raw.Header.EventID != "" { + eventMap["event_id"] = raw.Header.EventID + } + if raw.Header.CreateTime != "" { + eventMap["timestamp"] = raw.Header.CreateTime + } + return eventMap +} + +func (p *GenericProcessor) DeduplicateKey(raw *RawEvent) string { return raw.Header.EventID } +func (p *GenericProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } diff --git a/shortcuts/event/processor_im_chat.go b/shortcuts/event/processor_im_chat.go new file mode 100644 index 00000000..585f3f0b --- /dev/null +++ b/shortcuts/event/processor_im_chat.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" +) + +// ── im.chat.updated_v1 ────────────────────────────────────────────────────── + +// ImChatUpdatedProcessor handles im.chat.updated_v1 events. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - chat_id: the group chat that was updated +// - operator_id: open_id of the user who made the change +// - external: whether this is an external (cross-tenant) chat +// - before_change: chat properties before the update (e.g. name, description) +// - after_change: chat properties after the update +type ImChatUpdatedProcessor struct{} + +func (p *ImChatUpdatedProcessor) EventType() string { return "im.chat.updated_v1" } + +func (p *ImChatUpdatedProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + ChatID string `json:"chat_id"` + OperatorID interface{} `json:"operator_id"` + External bool `json:"external"` + AfterChange interface{} `json:"after_change"` + BeforeChange interface{} `json:"before_change"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + if ev.ChatID != "" { + out["chat_id"] = ev.ChatID + } + if id := openID(ev.OperatorID); id != "" { + out["operator_id"] = id + } + out["external"] = ev.External + if ev.AfterChange != nil { + out["after_change"] = ev.AfterChange + } + if ev.BeforeChange != nil { + out["before_change"] = ev.BeforeChange + } + return out +} + +func (p *ImChatUpdatedProcessor) DeduplicateKey(raw *RawEvent) string { + return raw.Header.EventID +} +func (p *ImChatUpdatedProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } + +// ── im.chat.disbanded_v1 ──────────────────────────────────────────────────── + +// ImChatDisbandedProcessor handles im.chat.disbanded_v1 events. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - chat_id: the group chat that was disbanded +// - operator_id: open_id of the user who disbanded the chat +// - external: whether this is an external (cross-tenant) chat +type ImChatDisbandedProcessor struct{} + +func (p *ImChatDisbandedProcessor) EventType() string { return "im.chat.disbanded_v1" } + +func (p *ImChatDisbandedProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + ChatID string `json:"chat_id"` + OperatorID interface{} `json:"operator_id"` + External bool `json:"external"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + if ev.ChatID != "" { + out["chat_id"] = ev.ChatID + } + if id := openID(ev.OperatorID); id != "" { + out["operator_id"] = id + } + out["external"] = ev.External + return out +} + +func (p *ImChatDisbandedProcessor) DeduplicateKey(raw *RawEvent) string { + return raw.Header.EventID +} +func (p *ImChatDisbandedProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } diff --git a/shortcuts/event/processor_im_chat_member.go b/shortcuts/event/processor_im_chat_member.go new file mode 100644 index 00000000..e0c209a6 --- /dev/null +++ b/shortcuts/event/processor_im_chat_member.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "strings" +) + +// ── im.chat.member.bot.added_v1 / deleted_v1 ──────────────────────────────── + +// ImChatBotProcessor handles im.chat.member.bot.added_v1 and deleted_v1. +// A single struct serves both event types; the concrete type is set via constructor. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - action: "added" or "removed" +// - chat_id: the group chat where the bot was added/removed +// - operator_id: open_id of the user who performed the action +// - external: whether this is an external (cross-tenant) chat +type ImChatBotProcessor struct { + eventType string +} + +// NewImChatBotAddedProcessor creates a processor for im.chat.member.bot.added_v1. +func NewImChatBotAddedProcessor() *ImChatBotProcessor { + return &ImChatBotProcessor{eventType: "im.chat.member.bot.added_v1"} +} + +// NewImChatBotDeletedProcessor creates a processor for im.chat.member.bot.deleted_v1. +func NewImChatBotDeletedProcessor() *ImChatBotProcessor { + return &ImChatBotProcessor{eventType: "im.chat.member.bot.deleted_v1"} +} + +func (p *ImChatBotProcessor) EventType() string { return p.eventType } + +func (p *ImChatBotProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + ChatID string `json:"chat_id"` + OperatorID interface{} `json:"operator_id"` + External bool `json:"external"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + action := "added" + if strings.Contains(p.eventType, "deleted") { + action = "removed" + } + out["action"] = action + if ev.ChatID != "" { + out["chat_id"] = ev.ChatID + } + if id := openID(ev.OperatorID); id != "" { + out["operator_id"] = id + } + out["external"] = ev.External + return out +} + +func (p *ImChatBotProcessor) DeduplicateKey(raw *RawEvent) string { return raw.Header.EventID } +func (p *ImChatBotProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } + +// ── im.chat.member.user.added_v1 / withdrawn_v1 / deleted_v1 ──────────────── + +// ImChatMemberUserProcessor handles im.chat.member.user.{added,withdrawn,deleted}_v1. +// A single struct serves all three event types; the concrete type is set via constructor. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - action: "added", "withdrawn" (user left), or "removed" (kicked by admin) +// - chat_id: the group chat affected +// - operator_id: open_id of the user who performed the action +// - user_ids: list of open_ids of the affected users +// - external: whether this is an external (cross-tenant) chat +type ImChatMemberUserProcessor struct { + eventType string +} + +// NewImChatMemberUserAddedProcessor creates a processor for im.chat.member.user.added_v1. +func NewImChatMemberUserAddedProcessor() *ImChatMemberUserProcessor { + return &ImChatMemberUserProcessor{eventType: "im.chat.member.user.added_v1"} +} + +// NewImChatMemberUserWithdrawnProcessor creates a processor for im.chat.member.user.withdrawn_v1. +func NewImChatMemberUserWithdrawnProcessor() *ImChatMemberUserProcessor { + return &ImChatMemberUserProcessor{eventType: "im.chat.member.user.withdrawn_v1"} +} + +// NewImChatMemberUserDeletedProcessor creates a processor for im.chat.member.user.deleted_v1. +func NewImChatMemberUserDeletedProcessor() *ImChatMemberUserProcessor { + return &ImChatMemberUserProcessor{eventType: "im.chat.member.user.deleted_v1"} +} + +func (p *ImChatMemberUserProcessor) EventType() string { return p.eventType } + +func (p *ImChatMemberUserProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + ChatID string `json:"chat_id"` + OperatorID interface{} `json:"operator_id"` + External bool `json:"external"` + Users []interface{} `json:"users"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + // Derive action from event type suffix + switch { + case strings.Contains(p.eventType, "added"): + out["action"] = "added" + case strings.Contains(p.eventType, "withdrawn"): + out["action"] = "withdrawn" + case strings.Contains(p.eventType, "deleted"): + out["action"] = "removed" + } + if ev.ChatID != "" { + out["chat_id"] = ev.ChatID + } + if id := openID(ev.OperatorID); id != "" { + out["operator_id"] = id + } + if userIDs := extractUserIDs(ev.Users); len(userIDs) > 0 { + out["user_ids"] = userIDs + } + out["external"] = ev.External + return out +} + +func (p *ImChatMemberUserProcessor) DeduplicateKey(raw *RawEvent) string { + return raw.Header.EventID +} +func (p *ImChatMemberUserProcessor) WindowStrategy() WindowConfig { + return WindowConfig{} +} diff --git a/shortcuts/event/processor_im_message.go b/shortcuts/event/processor_im_message.go new file mode 100644 index 00000000..68433c8b --- /dev/null +++ b/shortcuts/event/processor_im_message.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/larksuite/cli/internal/output" + convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib" +) + +// ImMessageProcessor handles im.message.receive_v1 events. +// +// Compact output fields: +// - type, id, message_id, create_time, timestamp +// - chat_id, chat_type, message_type, sender_id +// - content: human-readable text converted via convertlib (supports text, post, image, file, card, etc.) +type ImMessageProcessor struct{} + +func (p *ImMessageProcessor) EventType() string { return "im.message.receive_v1" } + +func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + + // Compact: unmarshal event portion into IM message structure + var ev struct { + Message struct { + MessageID string `json:"message_id"` + ChatID string `json:"chat_id"` + ChatType string `json:"chat_type"` + MessageType string `json:"message_type"` + Content string `json:"content"` + CreateTime string `json:"create_time"` + Mentions []interface{} `json:"mentions"` + } `json:"message"` + Sender struct { + SenderID struct { + OpenID string `json:"open_id"` + } `json:"sender_id"` + } `json:"sender"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + + // Card messages (interactive) are not yet supported for compact conversion; + // return raw event data directly. + if ev.Message.MessageType == "interactive" { + fmt.Fprintf(os.Stderr, "%s[hint]%s card message (interactive) compact conversion is not yet supported, returning raw event data\n", output.Dim, output.Reset) + return raw + } + + // Use convertlib to convert raw content JSON into human-readable text. + // Resolves @mention keys (e.g. @_user_1) to display names. + content := convertlib.ConvertBodyContent(ev.Message.MessageType, &convertlib.ConvertContext{ + RawContent: ev.Message.Content, + MentionMap: convertlib.BuildMentionKeyMap(ev.Message.Mentions), + }) + + // Build compact output with core message metadata + out := map[string]interface{}{ + "type": raw.Header.EventType, + } + if ev.Message.MessageID != "" { + out["id"] = ev.Message.MessageID + out["message_id"] = ev.Message.MessageID + } + if ev.Message.CreateTime != "" { + out["create_time"] = ev.Message.CreateTime + } + // Prefer header-level timestamp; fall back to message create_time + if raw.Header.CreateTime != "" { + out["timestamp"] = raw.Header.CreateTime + } else if ev.Message.CreateTime != "" { + out["timestamp"] = ev.Message.CreateTime + } + if ev.Message.ChatID != "" { + out["chat_id"] = ev.Message.ChatID + } + if ev.Message.ChatType != "" { + out["chat_type"] = ev.Message.ChatType + } + if ev.Message.MessageType != "" { + out["message_type"] = ev.Message.MessageType + } + if ev.Sender.SenderID.OpenID != "" { + out["sender_id"] = ev.Sender.SenderID.OpenID + } + if content != "" { + out["content"] = content + } + return out +} + +func (p *ImMessageProcessor) DeduplicateKey(raw *RawEvent) string { return raw.Header.EventID } +func (p *ImMessageProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } diff --git a/shortcuts/event/processor_im_message_reaction.go b/shortcuts/event/processor_im_message_reaction.go new file mode 100644 index 00000000..3c56d83c --- /dev/null +++ b/shortcuts/event/processor_im_message_reaction.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "strings" +) + +// ImMessageReactionProcessor handles im.message.reaction.created_v1 and deleted_v1. +// A single struct serves both event types; the concrete type is set via constructor. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - action: "added" (created) or "removed" (deleted) +// - message_id: the message that was reacted to +// - emoji_type: the emoji used (e.g. "THUMBSUP") +// - operator_id: open_id of the user who added/removed the reaction +// - action_time: Unix timestamp of the action +type ImMessageReactionProcessor struct { + eventType string +} + +// NewImReactionCreatedProcessor creates a processor for im.message.reaction.created_v1. +func NewImReactionCreatedProcessor() *ImMessageReactionProcessor { + return &ImMessageReactionProcessor{eventType: "im.message.reaction.created_v1"} +} + +// NewImReactionDeletedProcessor creates a processor for im.message.reaction.deleted_v1. +func NewImReactionDeletedProcessor() *ImMessageReactionProcessor { + return &ImMessageReactionProcessor{eventType: "im.message.reaction.deleted_v1"} +} + +func (p *ImMessageReactionProcessor) EventType() string { return p.eventType } + +func (p *ImMessageReactionProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + MessageID string `json:"message_id"` + ReactionType struct { + EmojiType string `json:"emoji_type"` + } `json:"reaction_type"` + OperatorType string `json:"operator_type"` + UserID struct { + OpenID string `json:"open_id"` + } `json:"user_id"` + ActionTime string `json:"action_time"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + action := "added" + if strings.Contains(p.eventType, "deleted") { + action = "removed" + } + out["action"] = action + if ev.MessageID != "" { + out["message_id"] = ev.MessageID + } + if ev.ReactionType.EmojiType != "" { + out["emoji_type"] = ev.ReactionType.EmojiType + } + if ev.UserID.OpenID != "" { + out["operator_id"] = ev.UserID.OpenID + } + if ev.ActionTime != "" { + out["action_time"] = ev.ActionTime + } + return out +} + +func (p *ImMessageReactionProcessor) DeduplicateKey(raw *RawEvent) string { + return raw.Header.EventID +} +func (p *ImMessageReactionProcessor) WindowStrategy() WindowConfig { + return WindowConfig{} +} diff --git a/shortcuts/event/processor_im_message_read.go b/shortcuts/event/processor_im_message_read.go new file mode 100644 index 00000000..da7bbce6 --- /dev/null +++ b/shortcuts/event/processor_im_message_read.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" +) + +// ── im.message.message_read_v1 ────────────────────────────────────────────── + +// ImMessageReadProcessor handles im.message.message_read_v1 events. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - reader_id: the open_id of the user who read the message +// - read_time: Unix timestamp of the read action +// - message_ids: list of message IDs that were read +type ImMessageReadProcessor struct{} + +func (p *ImMessageReadProcessor) EventType() string { return "im.message.message_read_v1" } + +func (p *ImMessageReadProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + Reader struct { + ReaderID struct { + OpenID string `json:"open_id"` + } `json:"reader_id"` + ReadTime string `json:"read_time"` + } `json:"reader"` + MessageIDList []string `json:"message_id_list"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + if ev.Reader.ReaderID.OpenID != "" { + out["reader_id"] = ev.Reader.ReaderID.OpenID + } + if ev.Reader.ReadTime != "" { + out["read_time"] = ev.Reader.ReadTime + } + if len(ev.MessageIDList) > 0 { + out["message_ids"] = ev.MessageIDList + } + return out +} + +func (p *ImMessageReadProcessor) DeduplicateKey(raw *RawEvent) string { + return raw.Header.EventID +} +func (p *ImMessageReadProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } diff --git a/shortcuts/event/processor_im_test.go b/shortcuts/event/processor_im_test.go new file mode 100644 index 00000000..63765f56 --- /dev/null +++ b/shortcuts/event/processor_im_test.go @@ -0,0 +1,501 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "testing" +) + +// --- im.message.message_read_v1 --- + +func TestImMessageReadProcessor_Compact(t *testing.T) { + p := &ImMessageReadProcessor{} + if p.EventType() != "im.message.message_read_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.message.message_read_v1", `{ + "reader": { + "reader_id": {"open_id": "ou_reader"}, + "read_time": "1700000001" + }, + "message_id_list": ["msg_001", "msg_002"] + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["type"] != "im.message.message_read_v1" { + t.Errorf("type = %v", result["type"]) + } + if result["reader_id"] != "ou_reader" { + t.Errorf("reader_id = %v", result["reader_id"]) + } + if result["read_time"] != "1700000001" { + t.Errorf("read_time = %v", result["read_time"]) + } + ids, ok := result["message_ids"].([]string) + if !ok || len(ids) != 2 { + t.Errorf("message_ids = %v", result["message_ids"]) + } +} + +func TestImMessageReadProcessor_Raw(t *testing.T) { + p := &ImMessageReadProcessor{} + raw := makeRawEvent("im.message.message_read_v1", `{}`) + result, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent) + if !ok { + t.Fatal("raw mode should return *RawEvent") + } + if result.Header.EventType != "im.message.message_read_v1" { + t.Errorf("EventType = %v", result.Header.EventType) + } +} + +func TestImMessageReadProcessor_UnmarshalError(t *testing.T) { + p := &ImMessageReadProcessor{} + raw := makeRawEvent("im.message.message_read_v1", `not json`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent) + if !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } + if result.Header.EventType != "im.message.message_read_v1" { + t.Errorf("EventType = %v", result.Header.EventType) + } +} + +func TestImMessageReadProcessor_Dedup(t *testing.T) { + p := &ImMessageReadProcessor{} + raw := makeRawEvent("im.message.message_read_v1", `{}`) + if k := p.DeduplicateKey(raw); k != "ev_test" { + t.Errorf("DeduplicateKey = %q", k) + } +} + +// --- im.message.reaction.created_v1 / deleted_v1 --- + +func TestImReactionCreatedProcessor_Compact(t *testing.T) { + p := NewImReactionCreatedProcessor() + if p.EventType() != "im.message.reaction.created_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.message.reaction.created_v1", `{ + "message_id": "msg_react", + "reaction_type": {"emoji_type": "THUMBSUP"}, + "operator_type": "user", + "user_id": {"open_id": "ou_reactor"}, + "action_time": "1700000002" + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "added" { + t.Errorf("action = %v, want added", result["action"]) + } + if result["message_id"] != "msg_react" { + t.Errorf("message_id = %v", result["message_id"]) + } + if result["emoji_type"] != "THUMBSUP" { + t.Errorf("emoji_type = %v", result["emoji_type"]) + } + if result["operator_id"] != "ou_reactor" { + t.Errorf("operator_id = %v", result["operator_id"]) + } + if result["action_time"] != "1700000002" { + t.Errorf("action_time = %v", result["action_time"]) + } +} + +func TestImReactionDeletedProcessor_Compact(t *testing.T) { + p := NewImReactionDeletedProcessor() + if p.EventType() != "im.message.reaction.deleted_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.message.reaction.deleted_v1", `{ + "message_id": "msg_unreact", + "reaction_type": {"emoji_type": "THUMBSUP"}, + "user_id": {"open_id": "ou_reactor"}, + "action_time": "1700000003" + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "removed" { + t.Errorf("action = %v, want removed", result["action"]) + } +} + +func TestImReactionProcessor_Raw(t *testing.T) { + p := NewImReactionCreatedProcessor() + raw := makeRawEvent("im.message.reaction.created_v1", `{}`) + if _, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent); !ok { + t.Fatal("raw mode should return *RawEvent") + } +} + +func TestImReactionProcessor_UnmarshalError(t *testing.T) { + p := NewImReactionCreatedProcessor() + raw := makeRawEvent("im.message.reaction.created_v1", `bad`) + if _, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent); !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } +} + +// --- im.chat.member.bot.added_v1 / deleted_v1 --- + +func TestImChatBotAddedProcessor_Compact(t *testing.T) { + p := NewImChatBotAddedProcessor() + if p.EventType() != "im.chat.member.bot.added_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.member.bot.added_v1", `{ + "chat_id": "oc_bot", + "operator_id": {"open_id": "ou_operator"}, + "external": false + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "added" { + t.Errorf("action = %v", result["action"]) + } + if result["chat_id"] != "oc_bot" { + t.Errorf("chat_id = %v", result["chat_id"]) + } + if result["operator_id"] != "ou_operator" { + t.Errorf("operator_id = %v", result["operator_id"]) + } + if result["external"] != false { + t.Errorf("external = %v", result["external"]) + } +} + +func TestImChatBotDeletedProcessor_Compact(t *testing.T) { + p := NewImChatBotDeletedProcessor() + if p.EventType() != "im.chat.member.bot.deleted_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.member.bot.deleted_v1", `{ + "chat_id": "oc_bot2", + "operator_id": {"open_id": "ou_op2"}, + "external": true + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "removed" { + t.Errorf("action = %v, want removed", result["action"]) + } + if result["external"] != true { + t.Errorf("external = %v, want true", result["external"]) + } +} + +func TestImChatBotProcessor_Raw(t *testing.T) { + p := NewImChatBotAddedProcessor() + raw := makeRawEvent("im.chat.member.bot.added_v1", `{}`) + if _, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent); !ok { + t.Fatal("raw mode should return *RawEvent") + } +} + +func TestImChatBotProcessor_UnmarshalError(t *testing.T) { + p := NewImChatBotAddedProcessor() + raw := makeRawEvent("im.chat.member.bot.added_v1", `{bad}`) + if _, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent); !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } +} + +// --- im.chat.member.user.added_v1 / withdrawn_v1 / deleted_v1 --- + +func TestImChatMemberUserAddedProcessor_Compact(t *testing.T) { + p := NewImChatMemberUserAddedProcessor() + if p.EventType() != "im.chat.member.user.added_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.member.user.added_v1", `{ + "chat_id": "oc_members", + "operator_id": {"open_id": "ou_admin"}, + "external": false, + "users": [ + {"user_id": {"open_id": "ou_user1"}, "name": "Alice"}, + {"user_id": {"open_id": "ou_user2"}, "name": "Bob"} + ] + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "added" { + t.Errorf("action = %v", result["action"]) + } + if result["chat_id"] != "oc_members" { + t.Errorf("chat_id = %v", result["chat_id"]) + } + if result["operator_id"] != "ou_admin" { + t.Errorf("operator_id = %v", result["operator_id"]) + } + userIDs, ok := result["user_ids"].([]string) + if !ok || len(userIDs) != 2 { + t.Fatalf("user_ids = %v", result["user_ids"]) + } + if userIDs[0] != "ou_user1" || userIDs[1] != "ou_user2" { + t.Errorf("user_ids = %v", userIDs) + } +} + +func TestImChatMemberUserWithdrawnProcessor_Compact(t *testing.T) { + p := NewImChatMemberUserWithdrawnProcessor() + if p.EventType() != "im.chat.member.user.withdrawn_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.member.user.withdrawn_v1", `{ + "chat_id": "oc_w", + "operator_id": {"open_id": "ou_self"}, + "external": false, + "users": [{"user_id": {"open_id": "ou_self"}, "name": "Self"}] + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "withdrawn" { + t.Errorf("action = %v, want withdrawn", result["action"]) + } +} + +func TestImChatMemberUserDeletedProcessor_Compact(t *testing.T) { + p := NewImChatMemberUserDeletedProcessor() + if p.EventType() != "im.chat.member.user.deleted_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.member.user.deleted_v1", `{ + "chat_id": "oc_del", + "operator_id": {"open_id": "ou_admin"}, + "users": [{"user_id": {"open_id": "ou_kicked"}}] + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "removed" { + t.Errorf("action = %v, want removed", result["action"]) + } +} + +func TestImChatMemberUserProcessor_Raw(t *testing.T) { + p := NewImChatMemberUserAddedProcessor() + raw := makeRawEvent("im.chat.member.user.added_v1", `{}`) + if _, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent); !ok { + t.Fatal("raw mode should return *RawEvent") + } +} + +func TestImChatMemberUserProcessor_UnmarshalError(t *testing.T) { + p := NewImChatMemberUserAddedProcessor() + raw := makeRawEvent("im.chat.member.user.added_v1", `bad json`) + if _, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent); !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } +} + +// --- im.chat.updated_v1 --- + +func TestImChatUpdatedProcessor_Compact(t *testing.T) { + p := &ImChatUpdatedProcessor{} + if p.EventType() != "im.chat.updated_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.updated_v1", `{ + "chat_id": "oc_updated", + "operator_id": {"open_id": "ou_updater"}, + "external": false, + "after_change": {"name": "New Name"}, + "before_change": {"name": "Old Name"} + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["type"] != "im.chat.updated_v1" { + t.Errorf("type = %v", result["type"]) + } + if result["chat_id"] != "oc_updated" { + t.Errorf("chat_id = %v", result["chat_id"]) + } + if result["operator_id"] != "ou_updater" { + t.Errorf("operator_id = %v", result["operator_id"]) + } + after, ok := result["after_change"].(map[string]interface{}) + if !ok { + t.Fatal("after_change should be a map") + } + if after["name"] != "New Name" { + t.Errorf("after_change.name = %v", after["name"]) + } + before, ok := result["before_change"].(map[string]interface{}) + if !ok { + t.Fatal("before_change should be a map") + } + if before["name"] != "Old Name" { + t.Errorf("before_change.name = %v", before["name"]) + } +} + +func TestImChatUpdatedProcessor_Raw(t *testing.T) { + p := &ImChatUpdatedProcessor{} + raw := makeRawEvent("im.chat.updated_v1", `{}`) + if _, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent); !ok { + t.Fatal("raw mode should return *RawEvent") + } +} + +func TestImChatUpdatedProcessor_UnmarshalError(t *testing.T) { + p := &ImChatUpdatedProcessor{} + raw := makeRawEvent("im.chat.updated_v1", `???`) + if _, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent); !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } +} + +// --- im.chat.disbanded_v1 --- + +func TestImChatDisbandedProcessor_Compact(t *testing.T) { + p := &ImChatDisbandedProcessor{} + if p.EventType() != "im.chat.disbanded_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.disbanded_v1", `{ + "chat_id": "oc_disbanded", + "operator_id": {"open_id": "ou_disbander"}, + "external": true + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["type"] != "im.chat.disbanded_v1" { + t.Errorf("type = %v", result["type"]) + } + if result["chat_id"] != "oc_disbanded" { + t.Errorf("chat_id = %v", result["chat_id"]) + } + if result["operator_id"] != "ou_disbander" { + t.Errorf("operator_id = %v", result["operator_id"]) + } + if result["external"] != true { + t.Errorf("external = %v, want true", result["external"]) + } +} + +func TestImChatDisbandedProcessor_Raw(t *testing.T) { + p := &ImChatDisbandedProcessor{} + raw := makeRawEvent("im.chat.disbanded_v1", `{}`) + if _, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent); !ok { + t.Fatal("raw mode should return *RawEvent") + } +} + +func TestImChatDisbandedProcessor_UnmarshalError(t *testing.T) { + p := &ImChatDisbandedProcessor{} + raw := makeRawEvent("im.chat.disbanded_v1", `nope`) + if _, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent); !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } +} + +// --- Registry: all IM processors registered --- + +func TestRegistryAllIMProcessors(t *testing.T) { + r := DefaultRegistry() + imTypes := []string{ + "im.message.receive_v1", + "im.message.message_read_v1", + "im.message.reaction.created_v1", + "im.message.reaction.deleted_v1", + "im.chat.member.bot.added_v1", + "im.chat.member.bot.deleted_v1", + "im.chat.member.user.added_v1", + "im.chat.member.user.withdrawn_v1", + "im.chat.member.user.deleted_v1", + "im.chat.updated_v1", + "im.chat.disbanded_v1", + } + for _, et := range imTypes { + p := r.Lookup(et) + if p.EventType() != et { + t.Errorf("Lookup(%q) returned processor with EventType=%q", et, p.EventType()) + } + } +} + +// --- Helper: openID --- + +func TestOpenID(t *testing.T) { + if id := openID(map[string]interface{}{"open_id": "ou_x"}); id != "ou_x" { + t.Errorf("got %q", id) + } + if id := openID("not a map"); id != "" { + t.Errorf("non-map should return empty, got %q", id) + } + if id := openID(nil); id != "" { + t.Errorf("nil should return empty, got %q", id) + } +} + +// --- Helper: extractUserIDs --- + +func TestExtractUserIDs(t *testing.T) { + users := []interface{}{ + map[string]interface{}{ + "user_id": map[string]interface{}{"open_id": "ou_a"}, + "name": "Alice", + }, + map[string]interface{}{ + "user_id": map[string]interface{}{"open_id": "ou_b"}, + }, + "not a map", + map[string]interface{}{ + "user_id": "not nested", + }, + } + ids := extractUserIDs(users) + if len(ids) != 2 || ids[0] != "ou_a" || ids[1] != "ou_b" { + t.Errorf("extractUserIDs = %v, want [ou_a, ou_b]", ids) + } +} + +func TestExtractUserIDs_Empty(t *testing.T) { + ids := extractUserIDs(nil) + if len(ids) != 0 { + t.Errorf("nil input should return empty, got %v", ids) + } +} + +// --- WindowStrategy for all new processors --- + +func TestWindowStrategy_IMProcessors(t *testing.T) { + processors := []EventProcessor{ + &ImMessageReadProcessor{}, + NewImReactionCreatedProcessor(), + NewImReactionDeletedProcessor(), + NewImChatBotAddedProcessor(), + NewImChatBotDeletedProcessor(), + NewImChatMemberUserAddedProcessor(), + NewImChatMemberUserWithdrawnProcessor(), + NewImChatMemberUserDeletedProcessor(), + &ImChatUpdatedProcessor{}, + &ImChatDisbandedProcessor{}, + } + for _, p := range processors { + if p.WindowStrategy() != (WindowConfig{}) { + t.Errorf("%s: WindowStrategy should return zero WindowConfig", p.EventType()) + } + } +} diff --git a/shortcuts/event/processor_test.go b/shortcuts/event/processor_test.go new file mode 100644 index 00000000..464cda88 --- /dev/null +++ b/shortcuts/event/processor_test.go @@ -0,0 +1,927 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" +) + +// chdirTemp changes cwd to a fresh temp dir for the test duration. +func chdirTemp(t *testing.T) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(orig) }) +} + +// helper to build a RawEvent from event-level JSON and header fields. +func makeRawEvent(eventType string, eventJSON string) *RawEvent { + return &RawEvent{ + Schema: "2.0", + Header: larkevent.EventHeader{ + EventType: eventType, + EventID: "ev_test", + }, + Event: json.RawMessage(eventJSON), + } +} + +// --- Registry --- + +func TestRegistryLookup(t *testing.T) { + r := DefaultRegistry() + p := r.Lookup("im.message.receive_v1") + if p.EventType() != "im.message.receive_v1" { + t.Errorf("got %q", p.EventType()) + } + p2 := r.Lookup("unknown.type") + if p2.EventType() != "" { + t.Errorf("fallback should have empty EventType, got %q", p2.EventType()) + } +} + +func TestRegistryDuplicateReturnsError(t *testing.T) { + r := NewProcessorRegistry(&GenericProcessor{}) + if err := r.Register(&ImMessageProcessor{}); err != nil { + t.Fatalf("first register should succeed: %v", err) + } + if err := r.Register(&ImMessageProcessor{}); err == nil { + t.Error("expected error on duplicate registration") + } +} + +// --- Filters --- + +func TestEventTypeFilter(t *testing.T) { + f := NewEventTypeFilter("im.message.receive_v1, drive.file.edit_v1") + if !f.Allow("im.message.receive_v1") { + t.Error("should allow") + } + if f.Allow("unknown.type") { + t.Error("should reject") + } +} + +func TestEventTypeFilter_Empty(t *testing.T) { + if f := NewEventTypeFilter(""); f != nil { + t.Error("empty should return nil") + } +} + +func TestRegexFilter(t *testing.T) { + f, err := NewRegexFilter("im\\.message\\..*") + if err != nil { + t.Fatal(err) + } + if !f.Allow("im.message.receive_v1") { + t.Error("should match") + } + if f.Allow("drive.file.edit_v1") { + t.Error("should not match") + } +} + +func TestRegexFilter_Invalid(t *testing.T) { + _, err := NewRegexFilter("[invalid") + if err == nil { + t.Error("should error") + } +} + +func TestFilterChain(t *testing.T) { + etf := NewEventTypeFilter("im.message.receive_v1, drive.file.edit_v1") + rf, _ := NewRegexFilter("im\\..*") + chain := NewFilterChain(etf, rf) + + if !chain.Allow("im.message.receive_v1") { + t.Error("both filters pass, should allow") + } + if chain.Allow("drive.file.edit_v1") { + t.Error("regex rejects drive, should block") + } + + empty := NewFilterChain() + if !empty.Allow("anything") { + t.Error("empty chain should allow all") + } + + var nilChain *FilterChain + if !nilChain.Allow("anything") { + t.Error("nil chain should allow all") + } +} + +func TestEventTypeFilter_TypesSorted(t *testing.T) { + f := NewEventTypeFilter("z.type,a.type,m.type") + got := f.Types() + want := []string{"a.type", "m.type", "z.type"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Types() = %v, want %v", got, want) + } +} + +// --- Processors --- + +func TestImMessageProcessor_Raw(t *testing.T) { + p := &ImMessageProcessor{} + eventJSON := `{"message":{"id":"1"}}` + raw := makeRawEvent("im.message.receive_v1", eventJSON) + result, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent) + if !ok { + t.Fatal("raw mode should return *RawEvent") + } + if result.Header.EventType != "im.message.receive_v1" { + t.Errorf("EventType = %v", result.Header.EventType) + } + if result.Schema != "2.0" { + t.Errorf("Schema = %v", result.Schema) + } +} + +func TestGenericProcessor_Compact(t *testing.T) { + p := &GenericProcessor{} + eventJSON := `{"file_token":"xxx"}` + raw := makeRawEvent("drive.file.edit_v1", eventJSON) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map[string]interface{}") + } + if result["file_token"] != "xxx" { + t.Error("file_token should be preserved") + } + if result["type"] != "drive.file.edit_v1" { + t.Errorf("type = %v, want drive.file.edit_v1", result["type"]) + } + if result["event_id"] != "ev_test" { + t.Errorf("event_id = %v, want ev_test", result["event_id"]) + } +} + +func TestGenericProcessor_Raw(t *testing.T) { + p := &GenericProcessor{} + eventJSON := `{"schema":"2.0"}` + raw := makeRawEvent("drive.file.edit_v1", eventJSON) + result, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent) + if !ok { + t.Fatal("raw mode should return *RawEvent") + } + if result.Header.EventType != "drive.file.edit_v1" { + t.Errorf("EventType = %v", result.Header.EventType) + } +} + +// --- Pipeline --- + +func TestPipeline_Raw(t *testing.T) { + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw}, &out, &errOut) + + eventJSON := `{"file_token":"xxx"}` + raw := makeRawEvent("drive.file.edit_v1", eventJSON) + raw.Header.EventID = "ev_raw" + raw.Header.CreateTime = "1700000000" + raw.Header.AppID = "cli_test" + p.Process(context.Background(), raw) + + // Raw output should be the complete original event (schema + header + event) + var outputMap map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &outputMap); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + if outputMap["schema"] != "2.0" { + t.Errorf("schema = %v, want 2.0", outputMap["schema"]) + } + header, ok := outputMap["header"].(map[string]interface{}) + if !ok { + t.Fatal("raw output should contain header object") + } + if header["event_type"] != "drive.file.edit_v1" { + t.Errorf("header.event_type = %v", header["event_type"]) + } + if header["app_id"] != "cli_test" { + t.Errorf("header.app_id = %v, want cli_test", header["app_id"]) + } +} + +func TestPipeline_Filtered(t *testing.T) { + filters := NewFilterChain(NewEventTypeFilter("im.message.receive_v1")) + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{}, &out, &errOut) + + raw := makeRawEvent("drive.file.edit_v1", `{}`) + p.Process(context.Background(), raw) + + if p.EventCount() != 0 { + t.Errorf("filtered event should not be counted") + } + if out.Len() != 0 { + t.Error("filtered event should produce no output") + } +} + +func TestDeduplicateKey(t *testing.T) { + raw := makeRawEvent("im.message.receive_v1", `{}`) + if k := (&ImMessageProcessor{}).DeduplicateKey(raw); k != "ev_test" { + t.Errorf("ImMessageProcessor got %q, want ev_test", k) + } + if k := (&GenericProcessor{}).DeduplicateKey(raw); k != "ev_test" { + t.Errorf("GenericProcessor got %q, want ev_test", k) + } +} + +func TestPipeline_Dedup(t *testing.T) { + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw}, &out, &errOut) + + raw := makeRawEvent("im.message.receive_v1", `{"message":{"id":"1"}}`) + + // First event should pass + p.Process(context.Background(), raw) + if p.EventCount() != 1 { + t.Fatalf("EventCount = %d, want 1", p.EventCount()) + } + firstLen := out.Len() + if firstLen == 0 { + t.Fatal("expected output from first event") + } + + // Same event_id again should be deduped + p.Process(context.Background(), raw) + if p.EventCount() != 1 { + t.Errorf("EventCount = %d, want 1 (deduped)", p.EventCount()) + } + if out.Len() != firstLen { + t.Error("duplicate event should produce no additional output") + } + + // Different event_id should pass + raw2 := makeRawEvent("im.message.receive_v1", `{"message":{"id":"2"}}`) + raw2.Header.EventID = "ev_other" + p.Process(context.Background(), raw2) + if p.EventCount() != 2 { + t.Errorf("EventCount = %d, want 2", p.EventCount()) + } +} + +// --- Pipeline: OutputDir --- + +func TestPipeline_OutputDir(t *testing.T) { + dir := t.TempDir() + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformCompact, OutputDir: dir}, &out, &errOut) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + + eventJSON := `{ + "message": { + "message_id": "msg_file", "chat_id": "oc_001", + "chat_type": "group", "message_type": "text", + "content": "{\"text\":\"file test\"}", "create_time": "1700000000" + }, + "sender": {"sender_id": {"open_id": "ou_001"}} + }` + raw := makeRawEvent("im.message.receive_v1", eventJSON) + raw.Header.EventID = "ev_file" + raw.Header.CreateTime = "1700000000" + p.Process(context.Background(), raw) + + // stdout should be empty (output goes to file) + if out.Len() != 0 { + t.Error("OutputDir mode should not write to stdout") + } + + // Verify file was created + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 file, got %d", len(entries)) + } + + // Verify file content is valid JSON + data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if err != nil { + t.Fatal(err) + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("file content is not valid JSON: %v", err) + } + if m["type"] != "im.message.receive_v1" { + t.Errorf("type = %v", m["type"]) + } +} + +// --- Pipeline: JsonFlag --- + +func TestPipeline_JsonFlag(t *testing.T) { + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw, JsonFlag: true}, &out, &errOut) + + raw := makeRawEvent("drive.file.edit_v1", `{"key":"val"}`) + p.Process(context.Background(), raw) + + // --json output should be pretty-printed (contain newlines + indentation) + output := out.String() + if !strings.Contains(output, "\n") { + t.Error("--json output should be pretty-printed") + } + + var m map[string]interface{} + if err := json.Unmarshal([]byte(output), &m); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } +} + +// --- Pipeline: Quiet --- + +func TestPipeline_Quiet(t *testing.T) { + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw, Quiet: true}, &out, &errOut) + + raw := makeRawEvent("im.message.receive_v1", `{}`) + p.Process(context.Background(), raw) + + if errOut.Len() != 0 { + t.Errorf("quiet mode should suppress stderr, got: %s", errOut.String()) + } +} + +// --- writeEventFile --- + +func TestWriteEventFile(t *testing.T) { + dir := t.TempDir() + header := larkevent.EventHeader{ + EventType: "im.message.receive_v1", + EventID: "ev_write", + CreateTime: "1700000000", + } + data := map[string]string{"hello": "world"} + + path, err := writeEventFile(dir, data, header) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(path, "ev_write") { + t.Errorf("path should contain event ID, got: %s", path) + } + + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), `"hello"`) { + t.Error("file should contain data") + } +} + +func TestWriteEventFile_EmptyFields(t *testing.T) { + dir := t.TempDir() + header := larkevent.EventHeader{EventType: "test.type"} + _, err := writeEventFile(dir, "data", header) + if err != nil { + t.Fatal(err) + } + + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatal("expected 1 file") + } + name := entries[0].Name() + if !strings.Contains(name, "unknown") { + t.Errorf("empty EventID should fallback to 'unknown', got: %s", name) + } +} + +// --- stderrLogger --- + +func TestStderrLogger(t *testing.T) { + var buf bytes.Buffer + l := &stderrLogger{w: &buf, quiet: false} + + l.Debug(context.Background(), "debug msg") + if buf.Len() != 0 { + t.Error("Debug should always be suppressed") + } + + l.Info(context.Background(), "info msg") + if !strings.Contains(buf.String(), "info msg") { + t.Error("Info should print when not quiet") + } + buf.Reset() + + l.Warn(context.Background(), "warn msg") + if !strings.Contains(buf.String(), "warn msg") { + t.Error("Warn should always print") + } + buf.Reset() + + l.Error(context.Background(), "error msg") + if !strings.Contains(buf.String(), "error msg") { + t.Error("Error should always print") + } +} + +func TestStderrLogger_Quiet(t *testing.T) { + var buf bytes.Buffer + l := &stderrLogger{w: &buf, quiet: true} + + l.Info(context.Background(), "info msg") + if buf.Len() != 0 { + t.Error("Info should be suppressed when quiet") + } + + l.Warn(context.Background(), "warn msg") + if !strings.Contains(buf.String(), "warn msg") { + t.Error("Warn should print even when quiet") + } +} + +// --- RegexFilter.String --- + +func TestRegexFilter_String(t *testing.T) { + f, _ := NewRegexFilter("im\\..*") + if f.String() != "im\\..*" { + t.Errorf("String() = %v", f.String()) + } +} + +// --- WindowStrategy --- + +func TestWindowStrategy(t *testing.T) { + im := &ImMessageProcessor{} + if im.WindowStrategy() != (WindowConfig{}) { + t.Error("should return zero WindowConfig") + } + gen := &GenericProcessor{} + if gen.WindowStrategy() != (WindowConfig{}) { + t.Error("should return zero WindowConfig") + } +} + +// --- Shortcuts --- + +func TestShortcuts(t *testing.T) { + s := Shortcuts() + if len(s) == 0 { + t.Fatal("should return at least one shortcut") + } + if s[0].Command != "+subscribe" { + t.Errorf("first shortcut command = %q", s[0].Command) + } +} + +// --- Compact unmarshal error fallback --- + +func TestImMessageProcessor_CompactUnmarshalError(t *testing.T) { + p := &ImMessageProcessor{} + raw := makeRawEvent("im.message.receive_v1", `not valid json`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent) + if !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } + if result.Header.EventType != "im.message.receive_v1" { + t.Errorf("EventType = %v", result.Header.EventType) + } +} + +func TestImMessageProcessor_CompactInteractiveFallsBackToRaw(t *testing.T) { + p := &ImMessageProcessor{} + raw := makeRawEvent("im.message.receive_v1", `{ + "message": { + "message_id": "om_interactive", + "message_type": "interactive", + "content": "{\"type\":\"template\"}" + } + }`) + + origStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error = %v", err) + } + os.Stderr = w + defer func() { + os.Stderr = origStderr + }() + + result, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent) + if err := w.Close(); err != nil { + t.Fatalf("stderr close error = %v", err) + } + hint, readErr := io.ReadAll(r) + if readErr != nil { + t.Fatalf("ReadAll(stderr) error = %v", readErr) + } + if !ok { + t.Fatal("interactive compact conversion should fallback to *RawEvent") + } + if result != raw { + t.Fatal("interactive compact conversion should return the original raw event") + } + if !strings.Contains(string(hint), "interactive") || !strings.Contains(string(hint), "returning raw event data") { + t.Fatalf("stderr hint = %q, want interactive fallback message", string(hint)) + } +} + +func TestGenericProcessor_CompactUnmarshalError(t *testing.T) { + p := &GenericProcessor{} + raw := makeRawEvent("some.type", `not valid json`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent) + if !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } + if result.Header.EventType != "some.type" { + t.Errorf("EventType = %v", result.Header.EventType) + } +} + +// --- Router --- + +func TestParseRoutes(t *testing.T) { + routes, err := ParseRoutes([]string{ + `^im\.message=dir:./messages/`, + `^contact\.=dir:./contacts/`, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routes == nil { + t.Fatal("expected non-nil router") + } + if len(routes.routes) != 2 { + t.Errorf("expected 2 routes, got %d", len(routes.routes)) + } +} + +func TestParseRoutes_Empty(t *testing.T) { + routes, err := ParseRoutes(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routes != nil { + t.Error("expected nil router for empty input") + } + + routes2, err2 := ParseRoutes([]string{}) + if err2 != nil { + t.Fatalf("unexpected error: %v", err2) + } + if routes2 != nil { + t.Error("expected nil router for empty slice") + } +} + +func TestParseRoutes_MissingEquals(t *testing.T) { + _, err := ParseRoutes([]string{"no-equals-sign"}) + if err == nil { + t.Error("expected error for missing =") + } +} + +func TestParseRoutes_InvalidRegex(t *testing.T) { + _, err := ParseRoutes([]string{"[invalid=dir:./foo/"}) + if err == nil { + t.Error("expected error for invalid regex") + } +} + +func TestParseRoutes_MissingPrefix(t *testing.T) { + _, err := ParseRoutes([]string{`^im\.message=./messages/`}) + if err == nil { + t.Error("expected error for missing dir: prefix") + } + if !strings.Contains(err.Error(), "dir:") { + t.Errorf("error should mention dir: prefix, got: %v", err) + } +} + +func TestParseRoutes_EmptyPath(t *testing.T) { + _, err := ParseRoutes([]string{`^im\.message=dir:`}) + if err == nil { + t.Error("expected error for empty path") + } +} + +func TestParseRoutes_RejectsAbsolutePath(t *testing.T) { + _, err := ParseRoutes([]string{`^test=dir:/tmp/evil`}) + if err == nil { + t.Error("expected error for absolute path in route") + } +} + +func TestParseRoutes_RejectsTraversal(t *testing.T) { + _, err := ParseRoutes([]string{`^test=dir:../../etc/evil`}) + if err == nil { + t.Error("expected error for path traversal in route") + } +} + +func TestParseRoutes_PathSafety(t *testing.T) { + routes, err := ParseRoutes([]string{`^test=dir:./foo/../bar/`}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + dir := routes.routes[0].dir + if !filepath.IsAbs(dir) { + t.Errorf("expected absolute path, got %s", dir) + } + if strings.Contains(dir, "..") { + t.Errorf("expected cleaned path without .., got %s", dir) + } +} + +func TestEventRouter_Match(t *testing.T) { + chdirTemp(t) + + router, err := ParseRoutes([]string{ + `^im\.message=dir:./test_messages`, + `^contact\.=dir:./test_contacts`, + }) + if err != nil { + t.Fatal(err) + } + + // Single match + dirs := router.Match("im.message.receive_v1") + if len(dirs) != 1 { + t.Errorf("expected 1 match, got %v", dirs) + } + + dirs = router.Match("contact.user.created_v3") + if len(dirs) != 1 { + t.Errorf("expected 1 match, got %v", dirs) + } + + // No match + dirs = router.Match("drive.file.edit_v1") + if len(dirs) != 0 { + t.Errorf("expected no match, got %v", dirs) + } +} + +func TestEventRouter_Match_FanOut(t *testing.T) { + chdirTemp(t) + + router, err := ParseRoutes([]string{ + `^im\.=dir:./test_im`, + `message=dir:./test_msg`, + }) + if err != nil { + t.Fatal(err) + } + + // "im.message.receive_v1" matches both patterns + dirs := router.Match("im.message.receive_v1") + if len(dirs) != 2 { + t.Errorf("expected 2 matches (fan-out), got %d: %v", len(dirs), dirs) + } +} + +// --- Pipeline: Route --- + +func TestPipeline_Route(t *testing.T) { + chdirTemp(t) + router, err := ParseRoutes([]string{ + `^im\.message=dir:./route_out`, + }) + if err != nil { + t.Fatal(err) + } + dir := router.routes[0].dir + + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformCompact, Router: router}, &out, &errOut) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + + eventJSON := `{ + "message": { + "message_id": "msg_route", "chat_id": "oc_001", + "chat_type": "group", "message_type": "text", + "content": "{\"text\":\"routed\"}", "create_time": "1700000000" + }, + "sender": {"sender_id": {"open_id": "ou_001"}} + }` + raw := makeRawEvent("im.message.receive_v1", eventJSON) + raw.Header.EventID = "ev_route" + raw.Header.CreateTime = "1700000000" + p.Process(context.Background(), raw) + + // stdout should be empty — output goes to route dir + if out.Len() != 0 { + t.Errorf("routed event should not appear on stdout, got: %s", out.String()) + } + + // Verify file was created in route dir + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 file in route dir, got %d", len(entries)) + } + + data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if err != nil { + t.Fatal(err) + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("file content is not valid JSON: %v", err) + } + if m["type"] != "im.message.receive_v1" { + t.Errorf("type = %v", m["type"]) + } +} + +func TestPipeline_Route_NoMatch(t *testing.T) { + chdirTemp(t) + fallbackDir := t.TempDir() + + router, err := ParseRoutes([]string{ + `^im\.message=dir:./route_dir`, + }) + if err != nil { + t.Fatal(err) + } + routeDir := router.routes[0].dir + + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformCompact, Router: router, OutputDir: fallbackDir}, &out, &errOut) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + + // Send an event that does NOT match the route + raw := makeRawEvent("drive.file.edit_v1", `{"file_token":"xxx"}`) + raw.Header.EventID = "ev_nomatch" + raw.Header.CreateTime = "1700000000" + p.Process(context.Background(), raw) + + // stdout should be empty + if out.Len() != 0 { + t.Errorf("should not appear on stdout, got: %s", out.String()) + } + + // Route dir should be empty + routeEntries, _ := os.ReadDir(routeDir) + if len(routeEntries) != 0 { + t.Errorf("route dir should be empty, got %d files", len(routeEntries)) + } + + // Fallback dir should have the file + fallbackEntries, _ := os.ReadDir(fallbackDir) + if len(fallbackEntries) != 1 { + t.Fatalf("fallback dir should have 1 file, got %d", len(fallbackEntries)) + } +} + +func TestPipeline_Route_NoMatch_Stdout(t *testing.T) { + chdirTemp(t) + + router, err := ParseRoutes([]string{ + `^im\.message=dir:./route_dir`, + }) + if err != nil { + t.Fatal(err) + } + routeDir := router.routes[0].dir + + filters := NewFilterChain() + var out, errOut bytes.Buffer + // No OutputDir — unmatched events should go to stdout + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw, Router: router}, &out, &errOut) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + + raw := makeRawEvent("drive.file.edit_v1", `{"file_token":"xxx"}`) + raw.Header.EventID = "ev_stdout" + raw.Header.CreateTime = "1700000000" + p.Process(context.Background(), raw) + + // Route dir should be empty + routeEntries, _ := os.ReadDir(routeDir) + if len(routeEntries) != 0 { + t.Errorf("route dir should be empty, got %d files", len(routeEntries)) + } + + // stdout should have the event + if out.Len() == 0 { + t.Error("unmatched event should fall through to stdout") + } + var m map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &m); err != nil { + t.Fatalf("stdout is not valid JSON: %v", err) + } +} + +func TestPipeline_Route_FanOut(t *testing.T) { + chdirTemp(t) + + router, err := ParseRoutes([]string{ + `^im\.=dir:./fanout1`, + `message=dir:./fanout2`, + }) + if err != nil { + t.Fatal(err) + } + dir1 := router.routes[0].dir + dir2 := router.routes[1].dir + + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformCompact, Router: router}, &out, &errOut) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + + eventJSON := `{ + "message": { + "message_id": "msg_fanout", "chat_id": "oc_001", + "chat_type": "group", "message_type": "text", + "content": "{\"text\":\"fanout\"}", "create_time": "1700000000" + }, + "sender": {"sender_id": {"open_id": "ou_001"}} + }` + raw := makeRawEvent("im.message.receive_v1", eventJSON) + raw.Header.EventID = "ev_fanout" + raw.Header.CreateTime = "1700000000" + p.Process(context.Background(), raw) + + // stdout should be empty + if out.Len() != 0 { + t.Errorf("fan-out event should not appear on stdout, got: %s", out.String()) + } + + // Both dirs should have a file + entries1, _ := os.ReadDir(dir1) + entries2, _ := os.ReadDir(dir2) + if len(entries1) != 1 { + t.Errorf("dir1 should have 1 file, got %d", len(entries1)) + } + if len(entries2) != 1 { + t.Errorf("dir2 should have 1 file, got %d", len(entries2)) + } +} + +// --- cleanupSeen --- + +func TestCleanupSeen(t *testing.T) { + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw}, &out, &errOut) + + // Insert an expired entry directly + p.seen.Store("old_key", time.Now().Add(-10*time.Minute)) + p.seen.Store("fresh_key", time.Now()) + + p.cleanupSeen(time.Now()) + + if _, ok := p.seen.Load("old_key"); ok { + t.Error("expired key should be cleaned up") + } + if _, ok := p.seen.Load("fresh_key"); !ok { + t.Error("fresh key should be kept") + } +} diff --git a/shortcuts/event/registry.go b/shortcuts/event/registry.go new file mode 100644 index 00000000..e51ef4f4 --- /dev/null +++ b/shortcuts/event/registry.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import "fmt" + +// ProcessorRegistry manages event_type → EventProcessor mappings. +type ProcessorRegistry struct { + processors map[string]EventProcessor + fallback EventProcessor +} + +// NewProcessorRegistry creates a registry with a fallback for unregistered event types. +func NewProcessorRegistry(fallback EventProcessor) *ProcessorRegistry { + return &ProcessorRegistry{ + processors: make(map[string]EventProcessor), + fallback: fallback, + } +} + +// Register adds a processor. Returns an error on duplicate event type registration. +func (r *ProcessorRegistry) Register(p EventProcessor) error { + et := p.EventType() + if _, exists := r.processors[et]; exists { + return fmt.Errorf("duplicate event processor for: %s", et) + } + r.processors[et] = p + return nil +} + +// Lookup finds a processor by event type. Returns fallback if not registered. Never returns nil. +func (r *ProcessorRegistry) Lookup(eventType string) EventProcessor { + if p, ok := r.processors[eventType]; ok { + return p + } + return r.fallback +} + +// DefaultRegistry builds the standard processor registry. +// To add a new processor, just add r.Register(...) here. +func DefaultRegistry() *ProcessorRegistry { + r := NewProcessorRegistry(&GenericProcessor{}) + // im.message + _ = r.Register(&ImMessageProcessor{}) + _ = r.Register(&ImMessageReadProcessor{}) + _ = r.Register(NewImReactionCreatedProcessor()) + _ = r.Register(NewImReactionDeletedProcessor()) + // im.chat.member + _ = r.Register(NewImChatBotAddedProcessor()) + _ = r.Register(NewImChatBotDeletedProcessor()) + _ = r.Register(NewImChatMemberUserAddedProcessor()) + _ = r.Register(NewImChatMemberUserWithdrawnProcessor()) + _ = r.Register(NewImChatMemberUserDeletedProcessor()) + // im.chat + _ = r.Register(&ImChatUpdatedProcessor{}) + _ = r.Register(&ImChatDisbandedProcessor{}) + return r +} diff --git a/shortcuts/event/router.go b/shortcuts/event/router.go new file mode 100644 index 00000000..07991647 --- /dev/null +++ b/shortcuts/event/router.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "fmt" + "regexp" + "strings" + + "github.com/larksuite/cli/internal/validate" +) + +// Route holds a compiled regex pattern and its target output directory. +type Route struct { + pattern *regexp.Regexp + dir string +} + +// EventRouter dispatches events to output directories by regex matching on event_type. +type EventRouter struct { + routes []Route +} + +// ParseRoutes parses route flag values into an EventRouter. +// Format: "regex=dir:./path/to/dir" +// Returns nil, nil when input is empty. +func ParseRoutes(specs []string) (*EventRouter, error) { + if len(specs) == 0 { + return nil, nil + } + + routes := make([]Route, 0, len(specs)) + for _, spec := range specs { + parts := strings.SplitN(spec, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid route %q: expected format regex=dir:./path", spec) + } + pattern := parts[0] + target := parts[1] + + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid regex in route %q: %w", spec, err) + } + + if !strings.HasPrefix(target, "dir:") { + return nil, fmt.Errorf("invalid route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target) + } + dir := strings.TrimPrefix(target, "dir:") + if dir == "" { + return nil, fmt.Errorf("invalid route %q: directory path is empty", spec) + } + + safeDir, err := validate.SafeOutputPath(dir) + if err != nil { + return nil, fmt.Errorf("invalid route %q: %w", spec, err) + } + + routes = append(routes, Route{pattern: re, dir: safeDir}) + } + + return &EventRouter{routes: routes}, nil +} + +// Match returns all target directories for the given event type. +// Returns nil if no routes match (caller should fall through to default output). +func (r *EventRouter) Match(eventType string) []string { + var dirs []string + for _, route := range r.routes { + if route.pattern.MatchString(eventType) { + dirs = append(dirs, route.dir) + } + } + return dirs +} diff --git a/shortcuts/event/shortcuts.go b/shortcuts/event/shortcuts.go new file mode 100644 index 00000000..94f55c73 --- /dev/null +++ b/shortcuts/event/shortcuts.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all event shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + EventSubscribe, + } +} diff --git a/shortcuts/event/subscribe.go b/shortcuts/event/subscribe.go new file mode 100644 index 00000000..5b3022e6 --- /dev/null +++ b/shortcuts/event/subscribe.go @@ -0,0 +1,294 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/lockfile" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" + "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" + larkws "github.com/larksuite/oapi-sdk-go/v3/ws" +) + +// stderrLogger redirects SDK log output to an io.Writer (stderr), +// preventing SDK logs from polluting the stdout data stream. +// Debug logs are always suppressed to avoid noisy event-loop output. +// When quiet is true, Info logs are also suppressed; Warn and Error always print. +type stderrLogger struct { + w io.Writer + quiet bool +} + +func (l *stderrLogger) Debug(_ context.Context, _ ...interface{}) {} +func (l *stderrLogger) Info(_ context.Context, args ...interface{}) { + if !l.quiet { + fmt.Fprintln(l.w, append([]interface{}{"[SDK Info]"}, args...)...) + } +} +func (l *stderrLogger) Warn(_ context.Context, args ...interface{}) { + fmt.Fprintln(l.w, append([]interface{}{"[SDK Warn]"}, args...)...) +} +func (l *stderrLogger) Error(_ context.Context, args ...interface{}) { + fmt.Fprintln(l.w, append([]interface{}{"[SDK Error]"}, args...)...) +} + +var _ larkcore.Logger = (*stderrLogger)(nil) + +// commonEventTypes are well-known event types registered in catch-all mode. +var commonEventTypes = []string{ + "im.message.receive_v1", + "im.message.message_read_v1", + "im.message.reaction.created_v1", + "im.message.reaction.deleted_v1", + "im.chat.member.bot.added_v1", + "im.chat.member.bot.deleted_v1", + "im.chat.member.user.added_v1", + "im.chat.member.user.withdrawn_v1", + "im.chat.member.user.deleted_v1", + "im.chat.updated_v1", + "im.chat.disbanded_v1", + "contact.user.created_v3", + "contact.user.updated_v3", + "contact.user.deleted_v3", + "contact.department.created_v3", + "contact.department.updated_v3", + "contact.department.deleted_v3", + "calendar.calendar.acl.created_v4", + "calendar.calendar.event.changed_v4", + "approval.approval.updated", + "application.application.visibility.added_v6", + "task.task.update_tenant_v1", + "task.task.comment_updated_v1", + "drive.notice.comment_add_v1", +} + +var EventSubscribe = common.Shortcut{ + Service: "event", + Command: "+subscribe", + Description: "Subscribe to Lark events via WebSocket (NDJSON output)", + Risk: "read", + Scopes: []string{}, // no direct OAPI; scopes depend on subscribed event types + AuthTypes: []string{"bot"}, + Flags: []common.Flag{ + // Output destination — where events go + {Name: "output-dir", Desc: "write each event as a JSON file in this directory (default: stdout)"}, + {Name: "route", Type: "string_array", Desc: "regex-based event routing (e.g. --route '^im\\.message=dir:./im/' --route '^contact\\.=dir:./contacts/'); unmatched events fall through to --output-dir or stdout"}, + // Output format — how events are serialized + {Name: "compact", Type: "bool", Desc: "flat key-value output: extract text, strip noise fields"}, + {Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"}, + // Filtering — which events reach the pipeline + {Name: "event-types", Desc: "comma-separated event types to subscribe; only use when you do not need other events (omit for catch-all)"}, + {Name: "filter", Desc: "regex to further filter events by event_type"}, + // Behavior + {Name: "quiet", Type: "bool", Desc: "suppress stderr status messages"}, + {Name: "force", Type: "bool", Desc: "bypass single-instance lock (UNSAFE: server randomly splits events across connections, each instance only receives a subset)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + eventTypesDisplay := "(catch-all)" + if s := runtime.Str("event-types"); s != "" { + eventTypesDisplay = s + } + filterDisplay := "(none)" + if s := runtime.Str("filter"); s != "" { + filterDisplay = s + } + outputDirDisplay := "(stdout)" + if s := runtime.Str("output-dir"); s != "" { + outputDirDisplay = s + } + routeDisplay := "(none)" + if routes := runtime.StrArray("route"); len(routes) > 0 { + routeDisplay = strings.Join(routes, "; ") + } + return common.NewDryRunAPI(). + Desc("Subscribe to Lark events via WebSocket (long-running)"). + Set("command", "event +subscribe"). + Set("app_id", runtime.Config.AppID). + Set("event_types", eventTypesDisplay). + Set("filter", filterDisplay).Set("output_dir", outputDirDisplay). + Set("route", routeDisplay) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + eventTypesStr := runtime.Str("event-types") + filterStr := runtime.Str("filter") + jsonFlag := runtime.Bool("json") + compactFlag := runtime.Bool("compact") + outputDir := runtime.Str("output-dir") + quietFlag := runtime.Bool("quiet") + routeSpecs := runtime.StrArray("route") + forceFlag := runtime.Bool("force") + + // Validate output directory path before any work + if outputDir != "" { + safePath, err := validate.SafeOutputPath(outputDir) + if err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + outputDir = safePath + } + + errOut := runtime.IO().ErrOut + out := runtime.IO().Out + + info := func(msg string) { + if !quietFlag { + fmt.Fprintln(errOut, msg) + } + } + + // --- Single-instance lock --- + if !forceFlag { + lock, err := lockfile.ForSubscribe(runtime.Config.AppID) + if err != nil { + return fmt.Errorf("failed to create lock: %w", err) + } + if err := lock.TryLock(); err != nil { + return output.ErrValidation( + "another event +subscribe instance is already running for app %s\n"+ + " Only one subscriber per app is allowed to prevent competing consumers.\n"+ + " Use --force to bypass this check.", + runtime.Config.AppID, + ) + } + defer lock.Unlock() + } + + // --- Build filter chain --- + eventTypeFilter := NewEventTypeFilter(eventTypesStr) + regexFilter, err := NewRegexFilter(filterStr) + if err != nil { + return output.ErrValidation("invalid --filter regex: %s", filterStr) + } + var filterList []EventFilter + if eventTypeFilter != nil { + filterList = append(filterList, eventTypeFilter) + } + if regexFilter != nil { + filterList = append(filterList, regexFilter) + } + filters := NewFilterChain(filterList...) + + // --- Parse route --- + router, err := ParseRoutes(routeSpecs) + if err != nil { + return output.ErrValidation("invalid --route: %v", err) + } + + // --- Build pipeline --- + mode := TransformRaw + if compactFlag { + mode = TransformCompact + } + pipeline := NewEventPipeline(DefaultRegistry(), filters, PipelineConfig{ + Mode: mode, + JsonFlag: jsonFlag, + OutputDir: outputDir, + Quiet: quietFlag, + Router: router, + }, out, errOut) + + if err := pipeline.EnsureDirs(); err != nil { + return err + } + + // --- Build SDK event dispatcher --- + rawHandler := func(ctx context.Context, event *larkevent.EventReq) error { + if event.Body == nil { + return nil + } + var raw RawEvent + if err := json.Unmarshal(event.Body, &raw); err != nil { + output.PrintError(errOut, fmt.Sprintf("failed to parse event: %v", err)) + return nil + } + pipeline.Process(ctx, &raw) + return nil + } + + sdkLogger := &stderrLogger{w: errOut, quiet: quietFlag} + + eventDispatcher := dispatcher.NewEventDispatcher("", "") + eventDispatcher.InitConfig(larkevent.WithLogger(sdkLogger)) + if eventTypeFilter != nil { + for _, et := range eventTypeFilter.Types() { + eventDispatcher.OnCustomizedEvent(et, rawHandler) + } + } else { + for _, et := range commonEventTypes { + eventDispatcher.OnCustomizedEvent(et, rawHandler) + } + } + + // --- WebSocket --- + domain := lark.FeishuBaseUrl + if runtime.Config.Brand == core.BrandLark { + domain = lark.LarkBaseUrl + } + + info(fmt.Sprintf("%sConnecting to Lark event WebSocket...%s", output.Cyan, output.Reset)) + if eventTypeFilter != nil { + info(fmt.Sprintf("Listening for: %s%s%s", output.Green, strings.Join(eventTypeFilter.Types(), ", "), output.Reset)) + } else { + info(fmt.Sprintf("Listening for %s%d common event types%s (catch-all mode)", output.Green, len(commonEventTypes), output.Reset)) + info(fmt.Sprintf("%sTip:%s use --event-types to listen for specific event types", output.Dim, output.Reset)) + } + if regexFilter != nil { + info(fmt.Sprintf("Filter: %s%s%s", output.Yellow, regexFilter.String(), output.Reset)) + } + if router != nil { + for _, spec := range routeSpecs { + info(fmt.Sprintf(" Route: %s%s%s", output.Green, spec, output.Reset)) + } + } + + cli := larkws.NewClient(runtime.Config.AppID, runtime.Config.AppSecret, + larkws.WithEventHandler(eventDispatcher), + larkws.WithDomain(domain), + larkws.WithLogger(sdkLogger), + ) + + // --- Graceful shutdown --- + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + + startErrCh := make(chan error, 1) + go func() { + startErrCh <- cli.Start(ctx) + }() + + info(fmt.Sprintf("%s%sConnected.%s Waiting for events... (Ctrl+C to stop)", output.Bold, output.Green, output.Reset)) + + select { + case sig, ok := <-sigCh: + if ok && sig != nil { + info(fmt.Sprintf("\n%sReceived %s, shutting down...%s (received %s%d%s events)", output.Yellow, sig, output.Reset, output.Bold, pipeline.EventCount(), output.Reset)) + } + return nil + case err, ok := <-startErrCh: + if !ok { + return nil + } + if err != nil { + return output.ErrNetwork("WebSocket connection failed: %v", err) + } + return nil + } + }, +} diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go new file mode 100644 index 00000000..36e06521 --- /dev/null +++ b/shortcuts/im/builders_test.go @@ -0,0 +1,633 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func mustMarshalDryRun(t *testing.T, v interface{}) string { + t.Helper() + + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + return string(b) +} + +func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "test"} + for name := range stringFlags { + cmd.Flags().String(name, "", "") + } + for name := range boolFlags { + cmd.Flags().Bool(name, false, "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, val := range stringFlags { + if err := cmd.Flags().Set(name, val); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + for name, val := range boolFlags { + if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +func TestBuildCreateChatBody(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "type": "public", + "name": "Team Chat", + "description": "daily sync", + "users": "ou_1, ou_2", + "bots": "cli_1, cli_2", + "owner": "ou_owner", + }, nil) + + got := buildCreateChatBody(runtime) + want := map[string]interface{}{ + "chat_type": "public", + "name": "Team Chat", + "description": "daily sync", + "user_id_list": []string{ + "ou_1", + "ou_2", + }, + "bot_id_list": []string{ + "cli_1", + "cli_2", + }, + "owner_id": "ou_owner", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildCreateChatBody() = %#v, want %#v", got, want) + } +} + +// TestSplitMembers verifies the delegation wrapper; core logic is tested in TestSplitCSV. [#17] +func TestSplitMembers(t *testing.T) { + got := common.SplitCSV(" ou_1, ,ou_2 ,, ou_3 ") + want := []string{"ou_1", "ou_2", "ou_3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("splitMembers() = %#v, want %#v", got, want) + } +} + +func TestBuildSearchChatBody(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "team-alpha", + "page-size": "50", + "page-token": "next_page", + }, nil) + + got := buildSearchChatBody(runtime) + want := map[string]interface{}{ + "query": `"team-alpha"`, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildSearchChatBody() = %#v, want %#v", got, want) + } +} + +func TestSplitAndTrimChat(t *testing.T) { + got := common.SplitCSV(" private, , public_joined ,, external ") + want := []string{"private", "public_joined", "external"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("common.SplitCSV() = %#v, want %#v", got, want) + } +} + +func TestBuildUpdateChatBody(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "name": "New Name", + "description": "New Description", + }, nil) + + got := buildUpdateChatBody(runtime) + want := map[string]interface{}{ + "name": "New Name", + "description": "New Description", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildUpdateChatBody() = %#v, want %#v", got, want) + } +} + +func TestIsMediaKey(t *testing.T) { + tests := []struct { + value string + want bool + }{ + {value: "img_123", want: true}, + {value: "file_123", want: true}, + {value: "/tmp/image.png", want: false}, + {value: "video.mp4", want: false}, + } + + for _, tt := range tests { + if got := isMediaKey(tt.value); got != tt.want { + t.Fatalf("isMediaKey(%q) = %v, want %v", tt.value, got, tt.want) + } + } +} + +func TestShortcutValidateBranches(t *testing.T) { + + t.Run("ImChatCreate valid", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "type": "public", + "name": "Team Room", + "users": "ou_1,ou_2", + "bots": "cli_1", + "owner": "ou_owner", + }, nil) + if err := ImChatCreate.Validate(context.Background(), runtime); err != nil { + t.Fatalf("ImChatCreate.Validate() unexpected error = %v", err) + } + }) + + t.Run("ImChatCreate name too long", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "name": strings.Repeat("长", 61), + }, nil) + err := ImChatCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--name exceeds the maximum of 60 characters") { + t.Fatalf("ImChatCreate.Validate() error = %v", err) + } + }) + + t.Run("ImChatCreate description too long", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "description": strings.Repeat("d", 101), + }, nil) + err := ImChatCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--description exceeds the maximum of 100 characters") { + t.Fatalf("ImChatCreate.Validate() error = %v", err) + } + }) + + t.Run("ImChatCreate invalid user id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "users": "ou_1,user_2", + }, nil) + err := ImChatCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid user ID format") { + t.Fatalf("ImChatCreate.Validate() error = %v", err) + } + }) + + t.Run("ImChatCreate too many bots", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "bots": "cli_1,cli_2,cli_3,cli_4,cli_5,cli_6", + }, nil) + err := ImChatCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--bots exceeds the maximum of 5") { + t.Fatalf("ImChatCreate.Validate() error = %v", err) + } + }) + + t.Run("ImChatCreate invalid owner id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "owner": "user_1", + }, nil) + err := ImChatCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid user ID format") { + t.Fatalf("ImChatCreate.Validate() error = %v", err) + } + }) + + t.Run("ImChatSearch invalid page size", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "ok", + "page-size": "0", + }, nil) + err := ImChatSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 100") { + t.Fatalf("ImChatSearch.Validate() error = %v", err) + } + }) + + t.Run("ImChatSearch query too long", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": strings.Repeat("q", 65), + }, nil) + err := ImChatSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--query exceeds the maximum of 64 characters") { + t.Fatalf("ImChatSearch.Validate() error = %v", err) + } + }) + + t.Run("ImChatUpdate requires fields", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + }, nil) + err := ImChatUpdate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "at least one field must be specified") { + t.Fatalf("ImChatUpdate.Validate() error = %v", err) + } + }) + + t.Run("ImChatUpdate invalid chat id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "bad_chat", + "name": "new", + }, nil) + err := ImChatUpdate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid chat ID format") { + t.Fatalf("ImChatUpdate.Validate() error = %v", err) + } + }) + + t.Run("ImChatUpdate description too long", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "description": strings.Repeat("x", 101), + }, nil) + err := ImChatUpdate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--description exceeds the maximum of 100 characters") { + t.Fatalf("ImChatUpdate.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend conflicting target", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "user-id": "ou_123", + "text": "hello", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--chat-id and --user-id are mutually exclusive") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend invalid content json", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "content": "{invalid", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--content is not valid JSON") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend media with text", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "text": "hello", + "image": "img_123", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--image/--file/--video/--audio cannot be used with --text, --markdown, or --content") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend valid text", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "text": "hello", + }, nil) + if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil { + t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err) + } + }) + + t.Run("ImMessagesSend video with video-cover passes validate", func(t *testing.T) { + // Previously broken: the deleted check used imageKey instead of videoCoverKey, + // so --video + --video-cover would incorrectly fail at Validate. + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "video": "file_456", + "video-cover": "img_789", + }, nil) + if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil { + t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err) + } + }) + + t.Run("ImMessagesSend video without video-cover fails validate", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "video": "file_456", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--video-cover is required when using --video") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend video-cover without video fails validate", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "video-cover": "img_789", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--video-cover can only be used with --video") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend conflicting explicit msg-type", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "msg-type": "file", + "image": "img_123", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "conflicts with the inferred message type") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesReply invalid message id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "bad_id", + "text": "hello", + }, nil) + err := ImMessagesReply.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "must start with om_") { + t.Fatalf("ImMessagesReply.Validate() error = %v", err) + } + }) + + t.Run("ImThreadsMessagesList invalid thread", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "thread": "bad_thread", + }, nil) + err := ImThreadsMessagesList.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "must start with om_ or omt_") { + t.Fatalf("ImThreadsMessagesList.Validate() error = %v", err) + } + }) + + t.Run("ImChatMessageList requires one target", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{}, nil) + err := ImChatMessageList.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "specify at least one of --chat-id or --user-id") { + t.Fatalf("ImChatMessageList.Validate() error = %v", err) + } + }) + + t.Run("ImChatMessageList valid user target", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-id": "ou_123", + }, nil) + if err := ImChatMessageList.Validate(context.Background(), runtime); err != nil { + t.Fatalf("ImChatMessageList.Validate() unexpected error = %v", err) + } + }) + + t.Run("ImMessagesMGet empty ids", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-ids": " , ", + }, nil) + err := ImMessagesMGet.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--message-ids is required") { + t.Fatalf("ImMessagesMGet.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesMGet invalid id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-ids": "om_1,bad_2", + }, nil) + err := ImMessagesMGet.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid message ID") { + t.Fatalf("ImMessagesMGet.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesResourcesDownload invalid message id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "bad_id", + "file-key": "img_123", + "type": "image", + }, nil) + err := ImMessagesResourcesDownload.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "must start with om_") { + t.Fatalf("ImMessagesResourcesDownload.Validate() error = %v", err) + } + }) + + t.Run("ImThreadsMessagesList valid omt thread", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "thread": "omt_123", + }, nil) + if err := ImThreadsMessagesList.Validate(context.Background(), runtime); err != nil { + t.Fatalf("ImThreadsMessagesList.Validate() unexpected error = %v", err) + } + }) + + t.Run("ImMessagesSearch invalid page size", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "incident", + "page-size": "0", + }, nil) + err := ImMessagesSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 50") { + t.Fatalf("ImMessagesSearch.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSearch invalid sender id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "sender": "user_1", + }, nil) + err := ImMessagesSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid user ID") { + t.Fatalf("ImMessagesSearch.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSearch invalid chat id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "bad_chat", + }, nil) + err := ImMessagesSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid chat ID") { + t.Fatalf("ImMessagesSearch.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSearch invalid time range", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "start": "2025-01-02T00:00:00Z", + "end": "2025-01-01T00:00:00Z", + }, nil) + err := ImMessagesSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--start cannot be later than --end") { + t.Fatalf("ImMessagesSearch.Validate() error = %v", err) + } + }) +} + +func TestShortcutDryRunShapes(t *testing.T) { + t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "type": "public", + "name": "Team Room", + "users": "ou_1,ou_2", + "owner": "ou_owner", + }, map[string]bool{ + "set-bot-manager": true, + }) + got := mustMarshalDryRun(t, ImChatCreate.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) { + t.Fatalf("ImChatCreate.DryRun() = %s", got) + } + }) + + t.Run("ImChatSearch dry run includes built params", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "team-alpha", + "page-size": "50", + "page-token": "next_page", + }, nil) + got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":20`) || !strings.Contains(got, `"query":"\"team-alpha\""`) { + t.Fatalf("ImChatSearch.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "incident", + "page-size": "51", + "page-token": "next_page", + }, nil) + got := mustMarshalDryRun(t, ImMessagesSearch.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/messages/search"`) || !strings.Contains(got, `"page_size":"50"`) || !strings.Contains(got, `"query":"incident"`) { + t.Fatalf("ImMessagesSearch.DryRun() = %s", got) + } + }) + + t.Run("ImChatUpdate dry run resolves path", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "name": "New Name", + "description": "New Description", + }, nil) + got := mustMarshalDryRun(t, ImChatUpdate.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/chats/oc_123"`) || !strings.Contains(got, `"user_id_type":"open_id"`) || !strings.Contains(got, `"name":"New Name"`) { + t.Fatalf("ImChatUpdate.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesSend dry run resolves open_id target", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-id": "ou_123", + "image": "img_123", + "idempotency-key": "uuid-2", + }, nil) + got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"receive_id_type":"open_id"`) || !strings.Contains(got, `"msg_type":"image"`) || !strings.Contains(got, `"uuid":"uuid-2"`) || !strings.Contains(got, `\"image_key\":\"img_123\"`) { + t.Fatalf("ImMessagesSend.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesSend dry run uses placeholder media key for url input", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "image": "https://example.com/a.png", + }, nil) + got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"description":"dry-run uses placeholder media keys for --image URL input; execution uploads it before sending"`) || + !strings.Contains(got, `"msg_type":"image"`) || + !strings.Contains(got, `\"image_key\":\"img_dryrun_upload\"`) { + t.Fatalf("ImMessagesSend.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesMGet dry run expands message ids", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-ids": "om_1,om_2", + }, nil) + got := mustMarshalDryRun(t, ImMessagesMGet.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/messages/mget?card_msg_content_type=raw_card_content\u0026message_ids=om_1\u0026message_ids=om_2"`) { + t.Fatalf("ImMessagesMGet.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesResourcesDownload dry run resolves path", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "om_123", + "file-key": "img_123", + "type": "image", + "output": "downloads/out.png", + }, nil) + got := mustMarshalDryRun(t, ImMessagesResourcesDownload.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/messages/om_123/resources/img_123"`) || !strings.Contains(got, `"type":"image"`) || !strings.Contains(got, `"output":"downloads/out.png"`) { + t.Fatalf("ImMessagesResourcesDownload.DryRun() = %s", got) + } + }) + + t.Run("ImThreadsMessagesList dry run keeps requested thread params", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "thread": "omt_123", + "sort": "desc", + "page-size": "10", + }, nil) + got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":10`) { + t.Fatalf("ImThreadsMessagesList.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesReply dry run resolves message path and body", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "om_123", + "text": "hi ", + "idempotency-key": "uuid-1", + }, map[string]bool{ + "reply-in-thread": true, + }) + got := mustMarshalDryRun(t, ImMessagesReply.DryRun(context.Background(), runtime)) + if !strings.Contains(got, "/open-apis/im/v1/messages/om_123/reply") || !strings.Contains(got, `"reply_in_thread":true`) || !strings.Contains(got, `"uuid":"uuid-1"`) { + t.Fatalf("ImMessagesReply.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesReply dry run uses markdown image placeholders", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "om_123", + "markdown": "![alt](https://example.com/a.png)", + }, nil) + got := mustMarshalDryRun(t, ImMessagesReply.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"description":"dry-run uses placeholder image keys for markdown image URLs; execution downloads and uploads them before sending"`) || + !strings.Contains(got, `"msg_type":"post"`) || + !strings.Contains(got, `img_dryrun_1`) { + t.Fatalf("ImMessagesReply.DryRun() = %s", got) + } + }) + + t.Run("ImChatMessageList dry run notes p2p resolution", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-id": "ou_123", + "page-size": "10", + "sort": "asc", + }, nil) + d := ImChatMessageList.DryRun(context.Background(), runtime) + formatted := d.Format() + if !strings.Contains(formatted, "resolve P2P chat_id") || !strings.Contains(formatted, "container_id=%3Cresolved_chat_id%3E") { + t.Fatalf("ImChatMessageList.DryRun().Format() = %s", formatted) + } + }) +} diff --git a/shortcuts/im/convert_lib/card.go b/shortcuts/im/convert_lib/card.go new file mode 100644 index 00000000..10c2c857 --- /dev/null +++ b/shortcuts/im/convert_lib/card.go @@ -0,0 +1,1548 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// cardObj is a convenience alias for generic JSON objects. +type cardObj = map[string]interface{} + +// cardMode controls output verbosity. +type cardMode int + +const ( + cardModeConcise cardMode = 0 + cardModeDetailed cardMode = 1 +) + +// ── Constants ───────────────────────────────────────────────────────────────── + +var cardEmojiMap = map[string]string{ + "OK": "👌", + "THUMBSUP": "👍", + "SMILE": "😊", + "HEART": "❤️", + "CLAP": "👏", + "FIRE": "🔥", + "PARTY": "🎉", + "THINK": "🤔", +} + +var cardChartTypeNames = map[string]string{ + "bar": "Bar chart", + "line": "Line chart", + "pie": "Pie chart", + "area": "Area chart", + "radar": "Radar chart", + "scatter": "Scatter plot", +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +type interactiveConverter struct{} + +func (interactiveConverter) Convert(ctx *ConvertContext) string { + return convertCard(ctx.RawContent) +} + +// convertCard converts a raw interactive/card message content JSON to human-readable string. +func convertCard(raw string) string { + var parsed cardObj + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return "[interactive card]" + } + + // raw_card_content format: outer JSON has "json_card" string field + if jsonCard, ok := parsed["json_card"].(string); ok { + c := &cardConverter{mode: cardModeConcise} + if att, ok := parsed["json_attachment"].(string); ok && att != "" { + var attObj cardObj + if json.Unmarshal([]byte(att), &attObj) == nil { + c.attachment = attObj + } + } + schema := 0 + if s, ok := parsed["card_schema"].(float64); ok { + schema = int(s) + } + result := c.convert(jsonCard, schema) + if result == "" { + return "[interactive card]" + } + return result + } + + // Legacy format + return convertLegacyCard(parsed) +} + +// ── Legacy converter ────────────────────────────────────────────────────────── + +func convertLegacyCard(parsed cardObj) string { + var texts []string + + if header, ok := parsed["header"].(cardObj); ok { + if title, ok := header["title"].(cardObj); ok { + if content, ok := title["content"].(string); ok && content != "" { + texts = append(texts, "**"+content+"**") + } + } + } + + body, _ := parsed["body"].(cardObj) + var elements []interface{} + if e, ok := parsed["elements"].([]interface{}); ok { + elements = e + } else if body != nil { + if e, ok := body["elements"].([]interface{}); ok { + elements = e + } + } + legacyExtractTexts(elements, &texts) + + if len(texts) == 0 { + return "[interactive card]" + } + return strings.Join(texts, "\n") +} + +func legacyExtractTexts(elements []interface{}, out *[]string) { + for _, el := range elements { + elem, ok := el.(cardObj) + if !ok { + continue + } + tag, _ := elem["tag"].(string) + + if tag == "markdown" { + if content, ok := elem["content"].(string); ok { + *out = append(*out, content) + } + continue + } + if tag == "div" || tag == "plain_text" || tag == "lark_md" { + if text, ok := elem["text"].(cardObj); ok { + if content, ok := text["content"].(string); ok && content != "" { + *out = append(*out, content) + } + } + if content, ok := elem["content"].(string); ok && content != "" { + *out = append(*out, content) + } + } + if tag == "column_set" { + if cols, ok := elem["columns"].([]interface{}); ok { + for _, col := range cols { + if cm, ok := col.(cardObj); ok { + if elems, ok := cm["elements"].([]interface{}); ok { + legacyExtractTexts(elems, out) + } + } + } + } + } + if elems, ok := elem["elements"].([]interface{}); ok { + legacyExtractTexts(elems, out) + } + } +} + +// ── CardConverter ───────────────────────────────────────────────────────────── + +type cardConverter struct { + mode cardMode + attachment cardObj +} + +func (c *cardConverter) convert(jsonCard string, hintSchema int) string { + var card cardObj + if err := json.Unmarshal([]byte(jsonCard), &card); err != nil { + return "\n[Unable to parse card content]\n" + } + + header, _ := card["header"].(cardObj) + title := "" + if header != nil { + title = c.extractHeaderTitle(header) + } + + bodyContent := "" + if body, ok := card["body"].(cardObj); ok { + bodyContent = c.convertBody(body) + } + + var sb strings.Builder + if title != "" { + sb.WriteString("\n") + } else { + sb.WriteString("\n") + } + if bodyContent != "" { + sb.WriteString(bodyContent) + sb.WriteString("\n") + } + sb.WriteString("") + return sb.String() +} + +func (c *cardConverter) extractHeaderTitle(header cardObj) string { + if prop, ok := header["property"].(cardObj); ok { + if titleElem, ok := prop["title"]; ok { + return c.extractTextContent(titleElem) + } + } + if titleElem, ok := header["title"]; ok { + return c.extractTextContent(titleElem) + } + return "" +} + +func (c *cardConverter) convertBody(body cardObj) string { + var elements []interface{} + + if prop, ok := body["property"].(cardObj); ok { + if e, ok := prop["elements"].([]interface{}); ok && len(e) > 0 { + elements = e + } + } + if len(elements) == 0 { + if e, ok := body["elements"].([]interface{}); ok { + elements = e + } + } + + if len(elements) == 0 { + return "" + } + return c.convertElements(elements, 0) +} + +func (c *cardConverter) convertElements(elements []interface{}, depth int) string { + var results []string + for _, el := range elements { + elem, ok := el.(cardObj) + if !ok { + continue + } + if result := c.convertElement(elem, depth); result != "" { + results = append(results, result) + } + } + return strings.Join(results, "\n") +} + +func (c *cardConverter) extractProperty(elem cardObj) cardObj { + if prop, ok := elem["property"].(cardObj); ok { + return prop + } + return elem +} + +func (c *cardConverter) convertElement(elem cardObj, depth int) string { + tag, _ := elem["tag"].(string) + id, _ := elem["id"].(string) + prop := c.extractProperty(elem) + + switch tag { + case "plain_text", "text": + return c.convertPlainText(prop) + case "markdown": + return c.convertMarkdown(prop) + case "markdown_v1": + return c.convertMarkdownV1(elem, prop) + case "div": + return c.convertDiv(prop, id) + case "note": + return c.convertNote(prop) + case "hr": + return "---" + case "br": + return "\n" + case "column_set": + return c.convertColumnSet(prop, depth) + case "column": + return c.convertColumn(prop, depth) + case "person": + return c.convertPerson(prop, id) + case "person_v1": + return c.convertPersonV1(prop, id) + case "person_list": + return c.convertPersonList(prop) + case "avatar": + return c.convertAvatar(prop, id) + case "at": + return c.convertAt(prop) + case "at_all": + return "@everyone" + case "button": + return c.convertButton(prop, id) + case "actions", "action": + return c.convertActions(prop) + case "overflow": + return c.convertOverflow(prop) + case "select_static", "select_person": + return c.convertSelect(prop, id, false) + case "multi_select_static", "multi_select_person": + return c.convertSelect(prop, id, true) + case "select_img": + return c.convertSelectImg(prop, id) + case "input": + return c.convertInput(prop, id) + case "date_picker": + return c.convertDatePicker(prop, id, "date") + case "picker_time": + return c.convertDatePicker(prop, id, "time") + case "picker_datetime": + return c.convertDatePicker(prop, id, "datetime") + case "checker": + return c.convertChecker(prop, id) + case "img", "image": + return c.convertImage(prop, id) + case "img_combination": + return c.convertImgCombination(prop) + case "table": + return c.convertTable(prop) + case "chart": + return c.convertChart(prop, id) + case "audio": + return c.convertAudio(prop, id) + case "video": + return c.convertVideo(prop, id) + case "collapsible_panel": + return c.convertCollapsiblePanel(prop, id) + case "form": + return c.convertForm(prop, id) + case "interactive_container": + return c.convertInteractiveContainer(prop, id) + case "text_tag": + return c.convertTextTag(prop) + case "number_tag": + return c.convertNumberTag(prop) + case "link": + return c.convertLink(prop) + case "emoji": + return c.convertEmoji(prop) + case "local_datetime": + return c.convertLocalDatetime(prop) + case "list": + return c.convertList(prop) + case "blockquote": + return c.convertBlockquote(prop) + case "code_block": + return c.convertCodeBlock(prop) + case "code_span": + return c.convertCodeSpan(prop) + case "heading": + return c.convertHeading(prop) + case "fallback_text": + return c.convertFallbackText(prop) + case "repeat": + return c.convertRepeat(prop) + case "card_header", "custom_icon", "standard_icon": + return "" + default: + return c.convertUnknown(prop, tag) + } +} + +// ── Text extraction ─────────────────────────────────────────────────────────── + +func (c *cardConverter) extractTextContent(v interface{}) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + m, ok := v.(cardObj) + if !ok { + return "" + } + if prop, ok := m["property"].(cardObj); ok { + return c.extractTextFromProperty(prop) + } + return c.extractTextFromProperty(m) +} + +func (c *cardConverter) extractTextFromProperty(prop cardObj) string { + // i18n content + if i18n, ok := prop["i18nContent"].(cardObj); ok { + for _, lang := range []string{"zh_cn", "en_us", "ja_jp"} { + if t, ok := i18n[lang].(string); ok && t != "" { + return t + } + } + } + if content, ok := prop["content"].(string); ok { + return content + } + if elements, ok := prop["elements"].([]interface{}); ok && len(elements) > 0 { + var texts []string + for _, el := range elements { + if t := c.extractTextContent(el); t != "" { + texts = append(texts, t) + } + } + return strings.Join(texts, "") + } + if text, ok := prop["text"].(string); ok { + return text + } + return "" +} + +// ── Element converters ──────────────────────────────────────────────────────── + +func (c *cardConverter) convertPlainText(prop cardObj) string { + content, _ := prop["content"].(string) + if content == "" { + return "" + } + return c.applyTextStyle(content, prop) +} + +func (c *cardConverter) convertMarkdown(prop cardObj) string { + if elements, ok := prop["elements"].([]interface{}); ok && len(elements) > 0 { + return c.convertMarkdownElements(elements) + } + if content, ok := prop["content"].(string); ok { + return content + } + return "" +} + +func (c *cardConverter) convertMarkdownV1(elem, prop cardObj) string { + if elements, ok := prop["elements"].([]interface{}); ok && len(elements) > 0 { + return c.convertMarkdownElements(elements) + } + if fallback, ok := elem["fallback"].(cardObj); ok { + return c.convertElement(fallback, 0) + } + if content, ok := prop["content"].(string); ok { + return content + } + return "" +} + +func (c *cardConverter) convertMarkdownElements(elements []interface{}) string { + var parts []string + for _, el := range elements { + elem, ok := el.(cardObj) + if !ok { + continue + } + if result := c.convertElement(elem, 0); result != "" { + parts = append(parts, result) + } + } + return strings.Join(parts, "") +} + +func (c *cardConverter) convertDiv(prop cardObj, _ string) string { + var results []string + + if textElem, ok := prop["text"].(cardObj); ok { + if text := c.convertElement(textElem, 0); text != "" { + if textSize, _ := textElem["text_size"].(string); textSize == "notation" { + text = "📝 " + text + } + results = append(results, text) + } + } + + if fields, ok := prop["fields"].([]interface{}); ok { + var fieldTexts []string + for _, field := range fields { + fm, ok := field.(cardObj) + if !ok { + continue + } + if te, ok := fm["text"].(cardObj); ok { + if ft := c.convertElement(te, 0); ft != "" { + fieldTexts = append(fieldTexts, ft) + } + } + } + if len(fieldTexts) > 0 { + results = append(results, strings.Join(fieldTexts, "\n")) + } + } + + if extraElem, ok := prop["extra"].(cardObj); ok { + if extra := c.convertElement(extraElem, 0); extra != "" { + results = append(results, extra) + } + } + + return strings.Join(results, "\n") +} + +func (c *cardConverter) convertNote(prop cardObj) string { + elements, _ := prop["elements"].([]interface{}) + if len(elements) == 0 { + return "" + } + var texts []string + for _, el := range elements { + elem, ok := el.(cardObj) + if !ok { + continue + } + if text := c.convertElement(elem, 0); text != "" { + texts = append(texts, text) + } + } + if len(texts) == 0 { + return "" + } + return "📝 " + strings.Join(texts, " ") +} + +func (c *cardConverter) convertLink(prop cardObj) string { + content, _ := prop["content"].(string) + if content == "" { + content = "Link" + } + urlStr := "" + if urlObj, ok := prop["url"].(cardObj); ok { + urlStr, _ = urlObj["url"].(string) + } + if urlStr != "" { + return fmt.Sprintf("[%s](%s)", escapeMDLinkText(content), urlStr) + } + return content +} + +func (c *cardConverter) convertEmoji(prop cardObj) string { + key, _ := prop["key"].(string) + if emoji, ok := cardEmojiMap[key]; ok { + return emoji + } + return ":" + key + ":" +} + +func (c *cardConverter) convertLocalDatetime(prop cardObj) string { + if ms, ok := prop["milliseconds"].(string); ok && ms != "" { + if formatted := cardFormatMillisToISO8601(ms); formatted != "" { + return formatted + } + } + fallback, _ := prop["fallbackText"].(string) + return fallback +} + +func (c *cardConverter) convertList(prop cardObj) string { + items, _ := prop["items"].([]interface{}) + if len(items) == 0 { + return "" + } + var lines []string + for _, item := range items { + im, ok := item.(cardObj) + if !ok { + continue + } + level := 0 + if l, ok := im["level"].(float64); ok { + level = int(l) + } + listType, _ := im["type"].(string) + order := 0 + if o, ok := im["order"].(float64); ok { + order = int(math.Floor(float64(o))) + } + indent := strings.Repeat(" ", level) + marker := "-" + if listType == "ol" { + marker = fmt.Sprintf("%d.", order) + } + if elements, ok := im["elements"].([]interface{}); ok { + content := c.convertMarkdownElements(elements) + lines = append(lines, fmt.Sprintf("%s%s %s", indent, marker, content)) + } + } + return strings.Join(lines, "\n") +} + +func (c *cardConverter) convertBlockquote(prop cardObj) string { + content := "" + if s, ok := prop["content"].(string); ok { + content = s + } else if elements, ok := prop["elements"].([]interface{}); ok { + content = c.convertMarkdownElements(elements) + } + if content == "" { + return "" + } + lines := strings.Split(content, "\n") + for i, line := range lines { + lines[i] = "> " + line + } + return strings.Join(lines, "\n") +} + +func (c *cardConverter) convertCodeBlock(prop cardObj) string { + language, _ := prop["language"].(string) + if language == "" { + language = "plaintext" + } + var code strings.Builder + if contents, ok := prop["contents"].([]interface{}); ok { + for _, line := range contents { + lm, ok := line.(cardObj) + if !ok { + continue + } + if lineContents, ok := lm["contents"].([]interface{}); ok { + for _, lc := range lineContents { + cm, ok := lc.(cardObj) + if !ok { + continue + } + if s, ok := cm["content"].(string); ok { + code.WriteString(s) + } + } + } + } + } + return fmt.Sprintf("```%s\n%s```", language, code.String()) +} + +func (c *cardConverter) convertCodeSpan(prop cardObj) string { + content, _ := prop["content"].(string) + return "`" + content + "`" +} + +func (c *cardConverter) convertHeading(prop cardObj) string { + level := 1 + if l, ok := prop["level"].(float64); ok { + level = int(l) + if level < 1 { + level = 1 + } + if level > 6 { + level = 6 + } + } + content := "" + if s, ok := prop["content"].(string); ok { + content = s + } else if elements, ok := prop["elements"].([]interface{}); ok { + content = c.convertMarkdownElements(elements) + } + return strings.Repeat("#", level) + " " + content +} + +func (c *cardConverter) convertFallbackText(prop cardObj) string { + if textElem, ok := prop["text"].(cardObj); ok { + return c.extractTextContent(textElem) + } + if elements, ok := prop["elements"].([]interface{}); ok { + return c.convertMarkdownElements(elements) + } + return "" +} + +func (c *cardConverter) convertTextTag(prop cardObj) string { + textElem := prop["text"] + text := c.extractTextContent(textElem) + if text == "" { + return "" + } + return "「" + text + "」" +} + +func (c *cardConverter) convertNumberTag(prop cardObj) string { + textElem := prop["text"] + text := c.extractTextContent(textElem) + if text == "" { + return "" + } + if urlObj, ok := prop["url"].(cardObj); ok { + if urlStr, ok := urlObj["url"].(string); ok && urlStr != "" { + return fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), urlStr) + } + } + return text +} + +func (c *cardConverter) convertUnknown(prop cardObj, tag string) string { + if prop != nil { + for _, path := range []string{"content", "text", "title", "label", "placeholder"} { + if v, ok := prop[path]; ok { + text := c.extractTextContent(v) + if text != "" { + return text + } + } + } + if elements, ok := prop["elements"].([]interface{}); ok && len(elements) > 0 { + return c.convertElements(elements, 0) + } + } + if c.mode == cardModeDetailed { + return fmt.Sprintf("[Unknown content](tag:%s)", tag) + } + return "[Unknown content]" +} + +func (c *cardConverter) convertColumnSet(prop cardObj, depth int) string { + columns, _ := prop["columns"].([]interface{}) + if len(columns) == 0 { + return "" + } + var results []string + for _, col := range columns { + elem, ok := col.(cardObj) + if !ok { + continue + } + if result := c.convertElement(elem, depth+1); result != "" { + results = append(results, result) + } + } + sep := "\n\n" + if allColumnsAreButtons(results) { + sep = " " + } + return strings.Join(results, sep) +} + +// allColumnsAreButtons reports whether every result looks like a button token +// (e.g. "[Text]", "[Text](url)", "[Text ✗]"). Used to decide whether +// column_set columns should be space-joined (button row) or newline-joined. +func allColumnsAreButtons(results []string) bool { + if len(results) == 0 { + return false + } + for _, r := range results { + if !strings.HasPrefix(r, "[") || strings.Contains(r, "\n") { + return false + } + } + return true +} + +func (c *cardConverter) convertColumn(prop cardObj, depth int) string { + elements, _ := prop["elements"].([]interface{}) + if len(elements) == 0 { + return "" + } + return c.convertElements(elements, depth) +} + +func (c *cardConverter) convertForm(prop cardObj, _ string) string { + var sb strings.Builder + sb.WriteString("
\n") + if elements, ok := prop["elements"].([]interface{}); ok { + sb.WriteString(c.convertElements(elements, 0)) + } + sb.WriteString("\n
") + return sb.String() +} + +func (c *cardConverter) convertCollapsiblePanel(prop cardObj, _ string) string { + expanded, _ := prop["expanded"].(bool) + title := "Details" + if header, ok := prop["header"].(cardObj); ok { + if titleElem, ok := header["title"]; ok { + if t := c.extractTextContent(titleElem); t != "" { + title = t + } + } + } + + shouldExpand := expanded || c.mode == cardModeDetailed + if shouldExpand { + var sb strings.Builder + sb.WriteString("▼ " + title + "\n") + if elements, ok := prop["elements"].([]interface{}); ok { + content := c.convertElements(elements, 1) + for _, line := range strings.Split(content, "\n") { + if line != "" { + sb.WriteString(" " + line + "\n") + } + } + } + sb.WriteString("▲") + return sb.String() + } + return "▶ " + title +} + +func (c *cardConverter) convertInteractiveContainer(prop cardObj, id string) string { + urlStr := "" + if actions, ok := prop["actions"].([]interface{}); ok && len(actions) > 0 { + if action, ok := actions[0].(cardObj); ok { + if actionType, _ := action["type"].(string); actionType == "open_url" { + if actionData, ok := action["action"].(cardObj); ok { + urlStr, _ = actionData["url"].(string) + } + } + } + } + + var sb strings.Builder + sb.WriteString("\n") + if elements, ok := prop["elements"].([]interface{}); ok { + sb.WriteString(c.convertElements(elements, 0)) + } + sb.WriteString("\n") + return sb.String() +} + +func (c *cardConverter) convertRepeat(prop cardObj) string { + if elements, ok := prop["elements"].([]interface{}); ok { + return c.convertElements(elements, 0) + } + return "" +} + +func (c *cardConverter) convertButton(prop cardObj, _ string) string { + buttonText := "" + if textElem, ok := prop["text"].(cardObj); ok { + buttonText = c.extractTextContent(textElem) + } + if buttonText == "" { + buttonText = "Button" + } + + disabled, _ := prop["disabled"].(bool) + if disabled && c.mode == cardModeConcise { + return fmt.Sprintf("[%s ✗]", buttonText) + } + + if actions, ok := prop["actions"].([]interface{}); ok { + for _, action := range actions { + am, ok := action.(cardObj) + if !ok { + continue + } + if am["type"] == "open_url" { + if ad, ok := am["action"].(cardObj); ok { + if urlStr, ok := ad["url"].(string); ok && urlStr != "" { + return fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr) + } + } + } + } + } + + if disabled && c.mode == cardModeDetailed { + result := fmt.Sprintf("[%s ✗]", buttonText) + if tips, ok := prop["disabledTips"].(cardObj); ok { + if tipsText := c.extractTextContent(tips); tipsText != "" { + result += fmt.Sprintf("(tips:\"%s\")", tipsText) + } + } + return result + } + + return fmt.Sprintf("[%s]", buttonText) +} + +func (c *cardConverter) convertActions(prop cardObj) string { + actions, _ := prop["actions"].([]interface{}) + if len(actions) == 0 { + return "" + } + var results []string + for _, action := range actions { + elem, ok := action.(cardObj) + if !ok { + continue + } + if result := c.convertElement(elem, 0); result != "" { + results = append(results, result) + } + } + return strings.Join(results, " ") +} + +func (c *cardConverter) convertOverflow(prop cardObj) string { + options, _ := prop["options"].([]interface{}) + if len(options) == 0 { + return "" + } + var optTexts []string + for _, opt := range options { + om, ok := opt.(cardObj) + if !ok { + continue + } + if textElem, ok := om["text"].(cardObj); ok { + if text := c.extractTextContent(textElem); text != "" { + optTexts = append(optTexts, text) + } + } + } + return "⋮ " + strings.Join(optTexts, ", ") +} + +func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) string { + options, _ := prop["options"].([]interface{}) + + selectedValues := map[string]bool{} + if isMulti { + if vals, ok := prop["selectedValues"].([]interface{}); ok { + for _, v := range vals { + if s, ok := v.(string); ok { + selectedValues[s] = true + } + } + } + } else { + if init, ok := prop["initialOption"].(string); ok { + selectedValues[init] = true + } + if idx, ok := prop["initialIndex"].(float64); ok { + i := int(idx) + if i >= 0 && i < len(options) { + if opt, ok := options[i].(cardObj); ok { + if val, ok := opt["value"].(string); ok { + selectedValues[val] = true + } + } + } + } + } + + var optionTexts []string + hasSelected := false + for _, opt := range options { + om, ok := opt.(cardObj) + if !ok { + continue + } + optText := "" + if textElem, ok := om["text"].(cardObj); ok { + optText = c.extractTextContent(textElem) + } + if optText == "" { + optText, _ = om["value"].(string) + } + if optText == "" { + continue + } + value, _ := om["value"].(string) + if selectedValues[value] { + optText = "✓" + optText + hasSelected = true + } + optionTexts = append(optionTexts, optText) + } + + if len(optionTexts) == 0 { + placeholder := "Please select" + if phElem, ok := prop["placeholder"].(cardObj); ok { + if ph := c.extractTextContent(phElem); ph != "" { + placeholder = ph + } + } + optionTexts = append(optionTexts, placeholder+" ▼") + } else if !hasSelected { + optionTexts[len(optionTexts)-1] += " ▼" + } + + result := "{" + strings.Join(optionTexts, " / ") + "}" + if c.mode == cardModeDetailed { + var attrs []string + if isMulti { + attrs = append(attrs, "multi") + } + if strings.Contains(id, "person") { + attrs = append(attrs, "type:person") + } + if len(attrs) > 0 { + result += "(" + strings.Join(attrs, " ") + ")" + } + } + return result +} + +func (c *cardConverter) convertSelectImg(prop cardObj, _ string) string { + options, _ := prop["options"].([]interface{}) + if len(options) == 0 { + return "" + } + selectedValues := map[string]bool{} + if vals, ok := prop["selectedValues"].([]interface{}); ok { + for _, v := range vals { + if s, ok := v.(string); ok { + selectedValues[s] = true + } + } + } + var optTexts []string + for i, opt := range options { + om, ok := opt.(cardObj) + if !ok { + continue + } + value, _ := om["value"].(string) + text := fmt.Sprintf("🖼️ Image %d", i+1) + if selectedValues[value] { + text = "✓" + text + } + optTexts = append(optTexts, text) + } + return "{" + strings.Join(optTexts, " / ") + "}" +} + +func (c *cardConverter) convertInput(prop cardObj, _ string) string { + label := "" + if labelElem, ok := prop["label"].(cardObj); ok { + label = c.extractTextContent(labelElem) + } + + defaultValue, _ := prop["defaultValue"].(string) + placeholder := "" + if phElem, ok := prop["placeholder"].(cardObj); ok { + placeholder = c.extractTextContent(phElem) + } + + var result string + switch { + case defaultValue != "": + result = defaultValue + "___" + case placeholder != "": + result = placeholder + "_____" + default: + result = "_____" + } + + if label != "" { + result = label + ": " + result + } + + if inputType, _ := prop["inputType"].(string); inputType == "multiline_text" { + result = strings.ReplaceAll(result, "_____", "...") + } + return result +} + +func (c *cardConverter) convertDatePicker(prop cardObj, _ string, pickerType string) string { + var emoji, value string + switch pickerType { + case "date": + emoji = "📅" + value, _ = prop["initialDate"].(string) + case "time": + emoji = "🕐" + value, _ = prop["initialTime"].(string) + case "datetime": + emoji = "📅" + value, _ = prop["initialDatetime"].(string) + default: + emoji = "📅" + } + + if value != "" { + value = cardNormalizeTimeFormat(value) + } + if value == "" { + placeholder := "Select" + if phElem, ok := prop["placeholder"].(cardObj); ok { + if ph := c.extractTextContent(phElem); ph != "" { + placeholder = ph + } + } + value = placeholder + } + return emoji + " " + value +} + +func (c *cardConverter) convertChecker(prop cardObj, id string) string { + checked, _ := prop["checked"].(bool) + checkMark := "[ ]" + if checked { + checkMark = "[x]" + } + text := "" + if textElem, ok := prop["text"].(cardObj); ok { + text = c.extractTextContent(textElem) + } + result := checkMark + " " + text + if c.mode == cardModeDetailed && id != "" { + result += "(id:" + id + ")" + } + return result +} + +func (c *cardConverter) convertImage(prop cardObj, _ string) string { + alt := "Image" + if altElem, ok := prop["alt"].(cardObj); ok { + if altText := c.extractTextContent(altElem); altText != "" { + alt = altText + } + } + if titleElem, ok := prop["title"].(cardObj); ok { + if titleText := c.extractTextContent(titleElem); titleText != "" { + alt = titleText + } + } + + result := "🖼️ " + alt + if c.mode == cardModeDetailed { + if imageID, ok := prop["imageID"].(string); ok && imageID != "" { + if token := c.getImageToken(imageID); token != "" { + result += "(img_token:" + token + ")" + } else { + result += "(img_key:" + imageID + ")" + } + } + } + return result +} + +func (c *cardConverter) convertImgCombination(prop cardObj) string { + imgList, _ := prop["imgList"].([]interface{}) + if len(imgList) == 0 { + return "" + } + result := fmt.Sprintf("🖼️ %d image(s)", len(imgList)) + if c.mode == cardModeDetailed { + var keys []string + for _, img := range imgList { + im, ok := img.(cardObj) + if !ok { + continue + } + if imageID, ok := im["imageID"].(string); ok && imageID != "" { + keys = append(keys, imageID) + } + } + if len(keys) > 0 { + result += "(keys:" + strings.Join(keys, ",") + ")" + } + } + return result +} + +func (c *cardConverter) convertChart(prop cardObj, _ string) string { + title := "Chart" + chartType := "" + + if chartSpec, ok := prop["chartSpec"].(cardObj); ok { + if titleObj, ok := chartSpec["title"].(cardObj); ok { + if text, ok := titleObj["text"].(string); ok && text != "" { + title = text + } + } + if ct, ok := chartSpec["type"].(string); ok && ct != "" { + chartType = ct + if typeName, ok := cardChartTypeNames[ct]; ok { + title += typeName + } + } + } + + summary := c.extractChartSummary(prop, chartType) + result := "📊 " + title + if summary != "" { + result += "\nSummary: " + summary + } + return result +} + +func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) string { + chartSpec, ok := prop["chartSpec"].(cardObj) + if !ok { + return "" + } + dataObj, ok := chartSpec["data"].(cardObj) + if !ok { + return "" + } + values, ok := dataObj["values"].([]interface{}) + if !ok || len(values) == 0 { + return "" + } + + switch chartType { + case "line", "bar", "area": + xField, _ := chartSpec["xField"].(string) + yField, _ := chartSpec["yField"].(string) + if xField == "" || yField == "" { + return fmt.Sprintf("%d data point(s)", len(values)) + } + var parts []string + for _, v := range values { + vm, ok := v.(cardObj) + if !ok { + continue + } + parts = append(parts, fmt.Sprintf("%v:%v", vm[xField], vm[yField])) + } + if len(parts) > 0 { + return strings.Join(parts, ", ") + } + case "pie": + catField, _ := chartSpec["categoryField"].(string) + valField, _ := chartSpec["valueField"].(string) + if catField == "" || valField == "" { + return fmt.Sprintf("%d data point(s)", len(values)) + } + var parts []string + for _, v := range values { + vm, ok := v.(cardObj) + if !ok { + continue + } + parts = append(parts, fmt.Sprintf("%v:%v", vm[catField], vm[valField])) + } + if len(parts) > 0 { + return strings.Join(parts, ", ") + } + } + return fmt.Sprintf("%d data point(s)", len(values)) +} + +func (c *cardConverter) convertAudio(prop cardObj, _ string) string { + result := "🎵 Audio" + if c.mode == cardModeDetailed { + fileID, _ := prop["fileID"].(string) + if fileID == "" { + fileID, _ = prop["audioID"].(string) + } + if fileID != "" { + result += "(key:" + fileID + ")" + } + } + return result +} + +func (c *cardConverter) convertVideo(prop cardObj, _ string) string { + result := "🎬 Video" + if c.mode == cardModeDetailed { + fileID, _ := prop["fileID"].(string) + if fileID == "" { + fileID, _ = prop["videoID"].(string) + } + if fileID != "" { + result += "(key:" + fileID + ")" + } + } + return result +} + +func (c *cardConverter) convertTable(prop cardObj) string { + columns, _ := prop["columns"].([]interface{}) + if len(columns) == 0 { + return "" + } + rows, _ := prop["rows"].([]interface{}) + + var colNames, colKeys []string + for _, col := range columns { + cm, ok := col.(cardObj) + if !ok { + continue + } + displayName, _ := cm["displayName"].(string) + name, _ := cm["name"].(string) + if displayName == "" { + displayName = name + } + colNames = append(colNames, displayName) + colKeys = append(colKeys, name) + } + + var lines []string + lines = append(lines, "| "+strings.Join(colNames, " | ")+" |") + separator := "|" + for range colNames { + separator += "------|" + } + lines = append(lines, separator) + + for _, row := range rows { + rm, ok := row.(cardObj) + if !ok { + continue + } + var cells []string + for _, key := range colKeys { + cellValue := "" + if cellData, ok := rm[key].(cardObj); ok { + if cellData["data"] != nil { + cellValue = c.extractTableCellValue(cellData["data"]) + } + } + cells = append(cells, cellValue) + } + lines = append(lines, "| "+strings.Join(cells, " | ")+" |") + } + return strings.Join(lines, "\n") +} + +func (c *cardConverter) extractTableCellValue(data interface{}) string { + switch v := data.(type) { + case string: + return v + case float64: + return strconv.FormatFloat(v, 'f', 2, 64) + case []interface{}: + var texts []string + for _, item := range v { + im, ok := item.(cardObj) + if !ok { + continue + } + if text, ok := im["text"].(string); ok { + texts = append(texts, "「"+text+"」") + } + } + return strings.Join(texts, " ") + default: + if m, ok := data.(cardObj); ok { + return c.extractTextContent(m) + } + return "" + } +} + +func (c *cardConverter) convertPerson(prop cardObj, _ string) string { + userID, _ := prop["userID"].(string) + if userID == "" { + return "" + } + personName := c.lookupPersonName(userID) + if personName == "" { + if notation, ok := prop["notation"].(cardObj); ok { + personName = c.extractTextContent(notation) + } + } + if personName != "" { + if c.mode == cardModeDetailed { + return fmt.Sprintf("@%s(open_id:%s)", personName, userID) + } + return "@" + personName + } + if c.mode == cardModeDetailed { + return fmt.Sprintf("@user(open_id:%s)", userID) + } + return "@" + userID +} + +// convertPersonV1 handles the v1 card schema person element. +// [#20] NOTE: this function duplicates ~20 lines from convertPerson with the only difference +// being the absence of the `notation` fallback block. Ideally it should delegate to +// convertPerson, but doing so would introduce the notation fallback for v1 schema elements +// (subtle behavior change). Not merged to preserve identical output behavior. +func (c *cardConverter) convertPersonV1(prop cardObj, _ string) string { + userID, _ := prop["userID"].(string) + if userID == "" { + return "" + } + personName := c.lookupPersonName(userID) + if personName != "" { + if c.mode == cardModeDetailed { + return fmt.Sprintf("@%s(open_id:%s)", personName, userID) + } + return "@" + personName + } + if c.mode == cardModeDetailed { + return fmt.Sprintf("@user(open_id:%s)", userID) + } + return "@" + userID +} + +func (c *cardConverter) convertPersonList(prop cardObj) string { + persons, _ := prop["persons"].([]interface{}) + if len(persons) == 0 { + return "" + } + var names []string + for _, person := range persons { + pm, ok := person.(cardObj) + if !ok { + continue + } + personID, _ := pm["id"].(string) + if c.mode == cardModeDetailed && personID != "" { + names = append(names, fmt.Sprintf("@user(id:%s)", personID)) + } else { + names = append(names, "@user") + } + } + return strings.Join(names, ", ") +} + +func (c *cardConverter) convertAvatar(prop cardObj, _ string) string { + userID, _ := prop["userID"].(string) + result := "👤" + if c.mode == cardModeDetailed && userID != "" { + result += "(id:" + userID + ")" + } + return result +} + +func (c *cardConverter) convertAt(prop cardObj) string { + userID, _ := prop["userID"].(string) + if userID == "" { + return "" + } + userName := "" + actualUserID := "" + if c.attachment != nil { + if atUsers, ok := c.attachment["at_users"].(cardObj); ok { + if userInfo, ok := atUsers[userID].(cardObj); ok { + userName, _ = userInfo["content"].(string) + actualUserID, _ = userInfo["user_id"].(string) + } + } + } + if userName != "" { + if c.mode == cardModeDetailed { + if actualUserID != "" { + return fmt.Sprintf("@%s(user_id:%s)", userName, actualUserID) + } + return fmt.Sprintf("@%s(open_id:%s)", userName, userID) + } + return "@" + userName + } + if c.mode == cardModeDetailed { + if actualUserID != "" { + return fmt.Sprintf("@user(user_id:%s)", actualUserID) + } + return fmt.Sprintf("@user(open_id:%s)", userID) + } + return "@" + userID +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func (c *cardConverter) lookupPersonName(userID string) string { + if c.attachment == nil { + return "" + } + if persons, ok := c.attachment["persons"].(cardObj); ok { + if person, ok := persons[userID].(cardObj); ok { + if content, ok := person["content"].(string); ok { + return content + } + } + } + return "" +} + +func (c *cardConverter) getImageToken(imageID string) string { + if c.attachment == nil { + return "" + } + if images, ok := c.attachment["images"].(cardObj); ok { + if imageInfo, ok := images[imageID].(cardObj); ok { + if token, ok := imageInfo["token"].(string); ok { + return token + } + } + } + return "" +} + +type cardTextStyle struct { + bold bool + italic bool + strikethrough bool +} + +func (c *cardConverter) extractTextStyle(prop cardObj) cardTextStyle { + style := cardTextStyle{} + textStyle, ok := prop["textStyle"].(cardObj) + if !ok { + return style + } + attrs, _ := textStyle["attributes"].([]interface{}) + for _, attr := range attrs { + s, ok := attr.(string) + if !ok { + continue + } + switch s { + case "bold": + style.bold = true + case "italic": + style.italic = true + case "strikethrough": + style.strikethrough = true + } + } + return style +} + +func (c *cardConverter) applyTextStyle(content string, prop cardObj) string { + if content == "" { + return content + } + style := c.extractTextStyle(prop) + if style.strikethrough { + content = "~~" + content + "~~" + } + if style.italic { + content = "*" + content + "*" + } + if style.bold { + content = "**" + content + "**" + } + return content +} + +// ── Utility functions ───────────────────────────────────────────────────────── + +func cardEscapeAttr(s string) string { + return cardAttrEscaper.Replace(s) +} + +var cardAttrEscaper = strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + "\n", `\n`, + "\r", `\r`, + "\t", `\t`, +) + +func cardFormatMillisToISO8601(ms string) string { + n, err := strconv.ParseInt(ms, 10, 64) + if err != nil { + return "" + } + t := time.Unix(n/1000, (n%1000)*int64(time.Millisecond)).UTC() + return t.Format(time.RFC3339) +} + +func cardNormalizeTimeFormat(input string) string { + if input == "" { + return "" + } + n, err := strconv.ParseInt(input, 10, 64) + if err == nil { + if len(input) >= 13 { + t := time.Unix(n/1000, (n%1000)*int64(time.Millisecond)).UTC() + return t.Format(time.RFC3339) + } else if len(input) >= 10 { + t := time.Unix(n, 0).UTC() + return t.Format(time.RFC3339) + } + } + // Already ISO8601 or date/time string + return input +} diff --git a/shortcuts/im/convert_lib/card_test.go b/shortcuts/im/convert_lib/card_test.go new file mode 100644 index 00000000..bb011f98 --- /dev/null +++ b/shortcuts/im/convert_lib/card_test.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "strings" + "testing" +) + +func newTestCardConverter(mode cardMode) *cardConverter { + return &cardConverter{ + mode: mode, + attachment: cardObj{ + "persons": cardObj{ + "ou_person": cardObj{"content": "Alice"}, + }, + "at_users": cardObj{ + "ou_at": cardObj{"content": "Bob", "user_id": "u_bob"}, + }, + "images": cardObj{ + "img_1": cardObj{"token": "img_tok_1"}, + }, + }, + } +} + +func TestConvertCard(t *testing.T) { + rawCard := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Card Title\"}},\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"hello\"}},{\"tag\":\"button\",\"property\":{\"text\":{\"content\":\"Open\"},\"actions\":[{\"type\":\"open_url\",\"action\":{\"url\":\"https://example.com\"}}]}}]}}","json_attachment":"{\"persons\":{\"ou_1\":{\"content\":\"Alice\"}}}"}` + got := convertCard(rawCard) + want := "\nhello\n[Open](https://example.com)\n" + if got != want { + t.Fatalf("convertCard(json_card) = %q, want %q", got, want) + } + + legacy := `{"header":{"title":{"content":"Legacy Card"}},"elements":[{"tag":"div","text":{"content":"legacy body"}}]}` + gotLegacy := convertCard(legacy) + wantLegacy := "**Legacy Card**\nlegacy body" + if gotLegacy != wantLegacy { + t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy) + } +} + +func TestCardUtilityFunctions(t *testing.T) { + if !allColumnsAreButtons([]string{"[Open]", "[More](https://example.com)"}) { + t.Fatal("allColumnsAreButtons() = false, want true") + } + if allColumnsAreButtons([]string{"plain text", "[Open]"}) { + t.Fatal("allColumnsAreButtons() = true, want false") + } + if got := cardEscapeAttr("a\\\"b\nc\rd\t"); got != "a\\\\\\\"b\\nc\\rd\\t" { + t.Fatalf("cardEscapeAttr() = %q", got) + } + if got := cardFormatMillisToISO8601("1710500000000"); got == "" { + t.Fatal("cardFormatMillisToISO8601() returned empty") + } + if got := cardNormalizeTimeFormat("1710500000"); got == "1710500000" { + t.Fatalf("cardNormalizeTimeFormat() did not normalize seconds: %q", got) + } + if got := cardNormalizeTimeFormat("2026-03-23"); got != "2026-03-23" { + t.Fatalf("cardNormalizeTimeFormat() = %q, want original value", got) + } +} + +func TestCardConverterMethods(t *testing.T) { + c := newTestCardConverter(cardModeDetailed) + + if got := c.convertLink(cardObj{"content": "Spec", "url": cardObj{"url": "https://example.com"}}); got != "[Spec](https://example.com)" { + t.Fatalf("convertLink() = %q", got) + } + if got := c.convertMarkdown(cardObj{"content": "**bold**"}); got != "**bold**" { + t.Fatalf("convertMarkdown() = %q", got) + } + if got := c.convertMarkdownV1(cardObj{"fallback": cardObj{"tag": "text", "property": cardObj{"content": "fallback"}}}, cardObj{}); got != "fallback" { + t.Fatalf("convertMarkdownV1() = %q", got) + } + if got := c.convertDiv(cardObj{ + "text": cardObj{"tag": "text", "property": cardObj{"content": "Title"}, "text_size": "notation"}, + "fields": []interface{}{cardObj{"text": cardObj{"tag": "text", "property": cardObj{"content": "Field 1"}}}}, + "extra": cardObj{"tag": "text", "property": cardObj{"content": "Extra"}}, + }, ""); got != "📝 Title\nField 1\nExtra" { + t.Fatalf("convertDiv() = %q", got) + } + if got := c.convertNote(cardObj{"elements": []interface{}{ + cardObj{"tag": "text", "property": cardObj{"content": "Tip"}}, + cardObj{"tag": "link", "property": cardObj{"content": "Doc", "url": cardObj{"url": "https://example.com/doc"}}}, + }}); got != "📝 Tip [Doc](https://example.com/doc)" { + t.Fatalf("convertNote() = %q", got) + } + if got := c.convertEmoji(cardObj{"key": "OK"}); got != "👌" { + t.Fatalf("convertEmoji() = %q", got) + } + if got := c.convertLocalDatetime(cardObj{"milliseconds": "1710500000000"}); got == "" { + t.Fatal("convertLocalDatetime() returned empty") + } + if got := c.convertList(cardObj{"items": []interface{}{ + cardObj{"level": float64(0), "type": "ul", "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "item1"}}}}, + cardObj{"level": float64(1), "type": "ol", "order": float64(2), "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "item2"}}}}, + }}); got != "- item1\n 2. item2" { + t.Fatalf("convertList() = %q", got) + } + if got := c.convertBlockquote(cardObj{"content": "line1\nline2"}); got != "> line1\n> line2" { + t.Fatalf("convertBlockquote() = %q", got) + } + if got := c.convertCodeBlock(cardObj{"language": "go", "contents": []interface{}{ + cardObj{"contents": []interface{}{cardObj{"content": "fmt.Println(1)"}}}, + }}); got != "```go\nfmt.Println(1)```" { + t.Fatalf("convertCodeBlock() = %q", got) + } + if got := c.convertCodeSpan(cardObj{"content": "x := 1"}); got != "`x := 1`" { + t.Fatalf("convertCodeSpan() = %q", got) + } + if got := c.convertHeading(cardObj{"level": float64(2), "content": "Title"}); got != "## Title" { + t.Fatalf("convertHeading() = %q", got) + } + if got := c.convertFallbackText(cardObj{"text": cardObj{"content": "fallback"}}); got != "fallback" { + t.Fatalf("convertFallbackText() = %q", got) + } + if got := c.convertTextTag(cardObj{"text": cardObj{"content": "Tag"}}); got != "「Tag」" { + t.Fatalf("convertTextTag() = %q", got) + } + if got := c.convertNumberTag(cardObj{"text": cardObj{"content": "42"}, "url": cardObj{"url": "https://example.com/42"}}); got != "[42](https://example.com/42)" { + t.Fatalf("convertNumberTag() = %q", got) + } + if got := c.convertUnknown(cardObj{"title": cardObj{"content": "mystery"}}, "unknown"); got != "mystery" { + t.Fatalf("convertUnknown() = %q", got) + } + if got := c.convertColumnSet(cardObj{"columns": []interface{}{ + cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "A"}}}}}, + cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "B"}}}}}, + }}, 0); got != "[A] [B]" { + t.Fatalf("convertColumnSet() = %q", got) + } + if got := c.convertForm(cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "form body"}}}}, ""); got != "
\nform body\n
" { + t.Fatalf("convertForm() = %q", got) + } + if got := c.convertCollapsiblePanel(cardObj{"expanded": true, "header": cardObj{"title": cardObj{"content": "More"}}, "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "inside"}}}}, ""); got != "▼ More\n inside\n▲" { + t.Fatalf("convertCollapsiblePanel() = %q", got) + } + if got := c.convertInteractiveContainer(cardObj{"actions": []interface{}{cardObj{"type": "open_url", "action": cardObj{"url": "https://example.com"}}}, "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Click here"}}}}, "cta_1"); got != "\nClick here\n" { + t.Fatalf("convertInteractiveContainer() = %q", got) + } + if got := c.convertRepeat(cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "repeat"}}}}); got != "repeat" { + t.Fatalf("convertRepeat() = %q", got) + } + if got := c.convertActions(cardObj{"actions": []interface{}{ + cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "One"}}}, + cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "Two"}}}, + }}); got != "[One] [Two]" { + t.Fatalf("convertActions() = %q", got) + } + if got := c.convertOverflow(cardObj{"options": []interface{}{ + cardObj{"text": cardObj{"content": "Edit"}}, + cardObj{"text": cardObj{"content": "Delete"}}, + }}); got != "⋮ Edit, Delete" { + t.Fatalf("convertOverflow() = %q", got) + } + if got := c.convertSelect(cardObj{ + "options": []interface{}{ + cardObj{"text": cardObj{"content": "Alice"}, "value": "a"}, + cardObj{"text": cardObj{"content": "Bob"}, "value": "b"}, + }, + "selectedValues": []interface{}{"a"}, + }, "select_person", true); got != "{✓Alice / Bob}(multi type:person)" { + t.Fatalf("convertSelect() = %q", got) + } + if got := c.convertSelectImg(cardObj{"options": []interface{}{cardObj{"value": "1"}, cardObj{"value": "2"}}, "selectedValues": []interface{}{"2"}}, ""); got != "{🖼️ Image 1 / ✓🖼️ Image 2}" { + t.Fatalf("convertSelectImg() = %q", got) + } + if got := c.convertInput(cardObj{"label": cardObj{"content": "Reason"}, "placeholder": cardObj{"content": "Type"}, "inputType": "multiline_text"}, ""); got != "Reason: Type..." { + t.Fatalf("convertInput() = %q", got) + } + if got := c.convertDatePicker(cardObj{"initialDate": "1710500000"}, "", "date"); got == "" || !strings.HasPrefix(got, "📅 ") { + t.Fatalf("convertDatePicker(date) = %q", got) + } + if got := c.convertChecker(cardObj{"checked": true, "text": cardObj{"content": "Done"}}, "chk_1"); got != "[x] Done(id:chk_1)" { + t.Fatalf("convertChecker() = %q", got) + } + if got := c.convertImage(cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}, ""); got != "🖼️ Poster(img_token:img_tok_1)" { + t.Fatalf("convertImage() = %q", got) + } + if got := c.convertImgCombination(cardObj{"imgList": []interface{}{cardObj{"imageID": "img_1"}, cardObj{"imageID": "img_2"}}}); got != "🖼️ 2 image(s)(keys:img_1,img_2)" { + t.Fatalf("convertImgCombination() = %q", got) + } + if got := c.convertChart(cardObj{"chartSpec": cardObj{ + "title": cardObj{"text": "Sales"}, + "type": "bar", + "xField": "month", + "yField": "value", + "data": cardObj{"values": []interface{}{ + cardObj{"month": "Jan", "value": 10}, + cardObj{"month": "Feb", "value": 20}, + }}, + }}, ""); got != "📊 SalesBar chart\nSummary: Jan:10, Feb:20" { + t.Fatalf("convertChart() = %q", got) + } + if got := c.convertAudio(cardObj{"fileID": "audio_1"}, ""); got != "🎵 Audio(key:audio_1)" { + t.Fatalf("convertAudio() = %q", got) + } + if got := c.convertVideo(cardObj{"videoID": "video_1"}, ""); got != "🎬 Video(key:video_1)" { + t.Fatalf("convertVideo() = %q", got) + } + if got := c.convertTable(cardObj{ + "columns": []interface{}{ + cardObj{"displayName": "Name", "name": "name"}, + cardObj{"displayName": "Score", "name": "score"}, + }, + "rows": []interface{}{ + cardObj{ + "name": cardObj{"data": "Alice"}, + "score": cardObj{"data": float64(95.5)}, + }, + }, + }); got != "| Name | Score |\n|------|------|\n| Alice | 95.50 |" { + t.Fatalf("convertTable() = %q", got) + } + if got := c.extractTableCellValue([]interface{}{cardObj{"text": "Tag 1"}, cardObj{"text": "Tag 2"}}); got != "「Tag 1」 「Tag 2」" { + t.Fatalf("extractTableCellValue() = %q", got) + } + if got := c.convertPerson(cardObj{"userID": "ou_person"}, ""); got != "@Alice(open_id:ou_person)" { + t.Fatalf("convertPerson() = %q", got) + } + if got := c.convertPersonV1(cardObj{"userID": "ou_person"}, ""); got != "@Alice(open_id:ou_person)" { + t.Fatalf("convertPersonV1() = %q", got) + } + if got := c.convertPersonList(cardObj{"persons": []interface{}{cardObj{"id": "u1"}, cardObj{"id": "u2"}}}); got != "@user(id:u1), @user(id:u2)" { + t.Fatalf("convertPersonList() = %q", got) + } + if got := c.convertAvatar(cardObj{"userID": "ou_person"}, ""); got != "👤(id:ou_person)" { + t.Fatalf("convertAvatar() = %q", got) + } + if got := c.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" { + t.Fatalf("convertAt() = %q", got) + } + if style := c.extractTextStyle(cardObj{"textStyle": cardObj{"attributes": []interface{}{"bold", "italic", "strikethrough"}}}); !style.bold || !style.italic || !style.strikethrough { + t.Fatalf("extractTextStyle() = %#v", style) + } + if got := c.applyTextStyle("hello", cardObj{"textStyle": cardObj{"attributes": []interface{}{"bold", "italic"}}}); got != "***hello***" { + t.Fatalf("applyTextStyle() = %q", got) + } + if got := (interactiveConverter{}).Convert(&ConvertContext{RawContent: `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"inside\"}}]}}"}`}); got != "\ninside\n" { + t.Fatalf("interactiveConverter.Convert() = %q", got) + } +} + +func TestCardConverterExtractTextHelpers(t *testing.T) { + c := newTestCardConverter(cardModeDetailed) + + if got := c.extractTextFromProperty(cardObj{ + "i18nContent": cardObj{ + "zh_cn": "你好", + "en_us": "hello", + }, + }); got != "你好" { + t.Fatalf("extractTextFromProperty(i18n) = %q", got) + } + + if got := c.extractTextFromProperty(cardObj{"content": "content-first"}); got != "content-first" { + t.Fatalf("extractTextFromProperty(content) = %q", got) + } + + if got := c.extractTextFromProperty(cardObj{ + "elements": []interface{}{ + cardObj{"property": cardObj{"content": "A"}}, + cardObj{"content": "B"}, + 123, + }, + }); got != "AB" { + t.Fatalf("extractTextFromProperty(elements) = %q", got) + } + + if got := c.extractTextFromProperty(cardObj{"text": "plain-text"}); got != "plain-text" { + t.Fatalf("extractTextFromProperty(text) = %q", got) + } + + if got := c.extractTextContent(cardObj{"property": cardObj{"content": "wrapped"}}); got != "wrapped" { + t.Fatalf("extractTextContent(property) = %q", got) + } + + if got := c.extractTextFromProperty(cardObj{}); got != "" { + t.Fatalf("extractTextFromProperty(empty) = %q, want empty", got) + } +} + +func TestCardConverterDispatch(t *testing.T) { + c := newTestCardConverter(cardModeDetailed) + + tests := []struct { + name string + elem cardObj + want string + contains string + }{ + {name: "plain text", elem: cardObj{"tag": "plain_text", "property": cardObj{"content": "hello"}}, want: "hello"}, + {name: "markdown", elem: cardObj{"tag": "markdown", "property": cardObj{"content": "**bold**"}}, want: "**bold**"}, + {name: "markdown v1", elem: cardObj{"tag": "markdown_v1", "fallback": cardObj{"tag": "text", "property": cardObj{"content": "fallback"}}}, want: "fallback"}, + {name: "div", elem: cardObj{"tag": "div", "property": cardObj{"text": cardObj{"tag": "text", "property": cardObj{"content": "Body"}}}}, want: "Body"}, + {name: "note", elem: cardObj{"tag": "note", "property": cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Tip"}}}}}, want: "📝 Tip"}, + {name: "hr", elem: cardObj{"tag": "hr"}, want: "---"}, + {name: "br", elem: cardObj{"tag": "br"}, want: "\n"}, + {name: "column set", elem: cardObj{"tag": "column_set", "property": cardObj{"columns": []interface{}{ + cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "A"}}}}}, + cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "B"}}}}}, + }}}, want: "[A] [B]"}, + {name: "person", elem: cardObj{"tag": "person", "property": cardObj{"userID": "ou_person"}}, want: "@Alice(open_id:ou_person)"}, + {name: "at", elem: cardObj{"tag": "at", "property": cardObj{"userID": "ou_at"}}, want: "@Bob(user_id:u_bob)"}, + {name: "at all", elem: cardObj{"tag": "at_all"}, want: "@everyone"}, + {name: "actions", elem: cardObj{"tag": "actions", "property": cardObj{"actions": []interface{}{ + cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "One"}}}, + cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "Two"}}}, + }}}, want: "[One] [Two]"}, + {name: "input", elem: cardObj{"tag": "input", "property": cardObj{"label": cardObj{"content": "Reason"}, "placeholder": cardObj{"content": "Type"}, "inputType": "multiline_text"}}, want: "Reason: Type..."}, + {name: "date", elem: cardObj{"tag": "date_picker", "property": cardObj{"initialDate": "1710500000"}}, contains: "📅 "}, + {name: "checker", elem: cardObj{"tag": "checker", "id": "chk_1", "property": cardObj{"checked": true, "text": cardObj{"content": "Done"}}}, want: "[x] Done(id:chk_1)"}, + {name: "image", elem: cardObj{"tag": "image", "property": cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}}, want: "🖼️ Poster(img_token:img_tok_1)"}, + {name: "interactive", elem: cardObj{"tag": "interactive_container", "id": "cta_1", "property": cardObj{ + "actions": []interface{}{cardObj{"type": "open_url", "action": cardObj{"url": "https://example.com"}}}, + "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Click here"}}}, + }}, want: "\nClick here\n"}, + {name: "text tag", elem: cardObj{"tag": "text_tag", "property": cardObj{"text": cardObj{"content": "Tag"}}}, want: "「Tag」"}, + {name: "link", elem: cardObj{"tag": "link", "property": cardObj{"content": "Spec", "url": cardObj{"url": "https://example.com"}}}, want: "[Spec](https://example.com)"}, + {name: "emoji", elem: cardObj{"tag": "emoji", "property": cardObj{"key": "OK"}}, want: "👌"}, + {name: "card header suppressed", elem: cardObj{"tag": "card_header"}, want: ""}, + {name: "unknown", elem: cardObj{"tag": "mystery", "property": cardObj{"title": cardObj{"content": "mystery"}}}, want: "mystery"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := c.convertElement(tt.elem, 0) + if tt.contains != "" { + if !strings.Contains(got, tt.contains) { + t.Fatalf("convertElement(%s) = %q, want containing %q", tt.name, got, tt.contains) + } + return + } + if got != tt.want { + t.Fatalf("convertElement(%s) = %q, want %q", tt.name, got, tt.want) + } + }) + } +} diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go new file mode 100644 index 00000000..8f9742d9 --- /dev/null +++ b/shortcuts/im/convert_lib/content_convert.go @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "fmt" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ContentConverter defines the interface for converting a message type's raw content to human-readable text. +type ContentConverter interface { + Convert(ctx *ConvertContext) string +} + +// ConvertContext holds all context needed for content conversion. +type ConvertContext struct { + RawContent string + MentionMap map[string]string + // MessageID and Runtime are used by merge_forward to fetch and expand sub-messages via API. + // For other message types these can be zero values. + MessageID string + Runtime *common.RuntimeContext + // SenderNames is a shared cache of open_id -> display name, accumulated across messages + // to avoid redundant contact API calls. May be nil. + SenderNames map[string]string +} + +// converters maps message types to their ContentConverter implementations. +var converters map[string]ContentConverter + +func init() { + converters = map[string]ContentConverter{ + "text": textConverter{}, + "post": postConverter{}, + "image": imageConverter{}, + "file": fileConverter{}, + "audio": audioMsgConverter{}, + "video": videoMsgConverter{}, + "media": videoMsgConverter{}, + "sticker": stickerConverter{}, + "interactive": interactiveConverter{}, + "share_chat": shareChatConverter{}, + "share_user": shareUserConverter{}, + "location": locationConverter{}, + "merge_forward": mergeForwardConverter{}, + "folder": folderConverter{}, + "share_calendar_event": calendarEventConverter{}, + "calendar": calendarInviteConverter{}, + "general_calendar": generalCalendarConverter{}, + "video_chat": videoChatConverter{}, + "system": systemConverter{}, + "todo": todoConverter{}, + "vote": voteConverter{}, + "hongbao": hongbaoConverter{}, + } +} + +// ConvertBodyContent converts body.content (a raw JSON string) to human-readable text. +func ConvertBodyContent(msgType string, ctx *ConvertContext) string { + if ctx.RawContent == "" { + return "" + } + if c, ok := converters[msgType]; ok { + return c.Convert(ctx) + } + return fmt.Sprintf("[%s]", msgType) +} + +// FormatEventMessage converts an event-pushed message to a human-readable map. +// Event messages have a different structure from API responses: +// - message_type (not msg_type), content is a direct JSON string (not under body.content) +// - mentions are nested under message.mentions +// +// This is the entry point for im.message.receive_v1 event processors. +func FormatEventMessage(msgType, rawContent, messageID string, mentions []interface{}) map[string]interface{} { + content := ConvertBodyContent(msgType, &ConvertContext{ + RawContent: rawContent, + MentionMap: BuildMentionKeyMap(mentions), + MessageID: messageID, + }) + + msg := map[string]interface{}{ + "msg_type": msgType, + "content": content, + } + + if len(mentions) > 0 { + simplified := make([]map[string]interface{}, 0, len(mentions)) + for _, raw := range mentions { + item, _ := raw.(map[string]interface{}) + key, _ := item["key"].(string) + name, _ := item["name"].(string) + simplified = append(simplified, map[string]interface{}{ + "key": key, + "id": extractMentionOpenId(item["id"]), + "name": name, + }) + } + msg["mentions"] = simplified + } + + return msg +} + +// FormatMessageItem converts a raw API message item to a human-readable map. +// senderNames is an optional shared cache (open_id -> name) accumulated across messages; +// pass nil to disable sender name caching. +func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, senderNames ...map[string]string) map[string]interface{} { + var nameCache map[string]string + if len(senderNames) > 0 { + nameCache = senderNames[0] + } + msgType, _ := m["msg_type"].(string) + messageId, _ := m["message_id"].(string) + mentions, _ := m["mentions"].([]interface{}) + deleted, _ := m["deleted"].(bool) + updated, _ := m["updated"].(bool) + + content := "" + if body, ok := m["body"].(map[string]interface{}); ok { + rawContent, _ := body["content"].(string) + content = ConvertBodyContent(msgType, &ConvertContext{ + RawContent: rawContent, + MentionMap: BuildMentionKeyMap(mentions), + MessageID: messageId, + Runtime: runtime, + SenderNames: nameCache, + }) + } + + msg := map[string]interface{}{ + "message_id": messageId, + "msg_type": msgType, + "content": content, + "sender": m["sender"], + "create_time": common.FormatTime(m["create_time"]), + "deleted": deleted, + "updated": updated, + } + + // thread_id takes priority; fall back to reply_to (parent_id) if no thread + if tid, _ := m["thread_id"].(string); tid != "" { + msg["thread_id"] = tid + } else if pid, _ := m["parent_id"].(string); pid != "" { + msg["reply_to"] = pid + } + + if len(mentions) > 0 { + simplified := make([]map[string]interface{}, 0, len(mentions)) + for _, raw := range mentions { + item, _ := raw.(map[string]interface{}) + key, _ := item["key"].(string) + name, _ := item["name"].(string) + simplified = append(simplified, map[string]interface{}{ + "key": key, + "id": extractMentionOpenId(item["id"]), + "name": name, + }) + } + msg["mentions"] = simplified + } + + return msg +} + +// extractMentionOpenId extracts open_id from mention id (string or {"open_id":...} object). +func extractMentionOpenId(id interface{}) string { + if s, ok := id.(string); ok { + return s + } + if m, ok := id.(map[string]interface{}); ok { + if openId, ok := m["open_id"].(string); ok { + return openId + } + } + return "" +} + +// TruncateContent truncates a string for table display. +func TruncateContent(s string, max int) string { + s = strings.ReplaceAll(s, "\n", " ") + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max]) + "…" +} diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go new file mode 100644 index 00000000..a36b7a0b --- /dev/null +++ b/shortcuts/im/convert_lib/content_media_misc_test.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestConvertBodyContent(t *testing.T) { + ctx := &ConvertContext{RawContent: `{"text":"hello"}`} + + if got := ConvertBodyContent("text", ctx); got != "hello" { + t.Fatalf("ConvertBodyContent(text) = %q, want %q", got, "hello") + } + if got := ConvertBodyContent("unknown_type", ctx); got != "[unknown_type]" { + t.Fatalf("ConvertBodyContent(unknown) = %q, want %q", got, "[unknown_type]") + } + if got := ConvertBodyContent("text", &ConvertContext{}); got != "" { + t.Fatalf("ConvertBodyContent(empty) = %q, want empty", got) + } +} + +func TestFormatMessageItem(t *testing.T) { + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_123", + "deleted": true, + "updated": true, + "thread_id": "omt_1", + "create_time": "1710500000", + "sender": map[string]interface{}{ + "id": "ou_sender", + "sender_type": "user", + }, + "mentions": []interface{}{ + map[string]interface{}{"key": "@_user_1", "id": map[string]interface{}{"open_id": "ou_alice"}, "name": "Alice"}, + }, + "body": map[string]interface{}{ + "content": `{"text":"hi @_user_1"}`, + }, + } + + got := FormatMessageItem(raw, nil) + if got["message_id"] != "om_123" { + t.Fatalf("FormatMessageItem() message_id = %#v", got["message_id"]) + } + if got["content"] != "hi @Alice" { + t.Fatalf("FormatMessageItem() content = %#v, want %#v", got["content"], "hi @Alice") + } + if got["create_time"] != common.FormatTime("1710500000") { + t.Fatalf("FormatMessageItem() create_time = %#v, want %#v", got["create_time"], common.FormatTime("1710500000")) + } + if got["thread_id"] != "omt_1" { + t.Fatalf("FormatMessageItem() thread_id = %#v, want %#v", got["thread_id"], "omt_1") + } + mentions, _ := got["mentions"].([]map[string]interface{}) + if len(mentions) != 1 || mentions[0]["id"] != "ou_alice" { + t.Fatalf("FormatMessageItem() mentions = %#v", got["mentions"]) + } +} + +func TestExtractMentionOpenIdAndTruncateContent(t *testing.T) { + if got := extractMentionOpenId("ou_1"); got != "ou_1" { + t.Fatalf("extractMentionOpenId(string) = %q", got) + } + if got := extractMentionOpenId(map[string]interface{}{"open_id": "ou_2"}); got != "ou_2" { + t.Fatalf("extractMentionOpenId(map) = %q", got) + } + if got := extractMentionOpenId(123); got != "" { + t.Fatalf("extractMentionOpenId(other) = %q, want empty", got) + } + + if got := TruncateContent("hello\nworld", 20); got != "hello world" { + t.Fatalf("TruncateContent(no truncate) = %q", got) + } + if got := TruncateContent("你好世界和平", 4); got != "你好世界…" { + t.Fatalf("TruncateContent(truncate) = %q", got) + } +} + +func TestMediaConverters(t *testing.T) { + if got := (imageConverter{}).Convert(&ConvertContext{RawContent: `{"image_key":"img_1"}`}); got != "[Image: img_1]" { + t.Fatalf("imageConverter.Convert() = %q", got) + } + if got := (imageConverter{}).Convert(&ConvertContext{RawContent: `{invalid`}); got != "[Invalid image JSON]" { + t.Fatalf("imageConverter.Convert(invalid) = %q", got) + } + if got := (fileConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"file_1","file_name":"demo.pdf"}`}); got != `` { + t.Fatalf("fileConverter.Convert() = %q", got) + } + if got := (fileConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"file_\"1","file_name":"demo\\\".pdf"}`}); got != `` { + t.Fatalf("fileConverter.Convert(escaped) = %q", got) + } + if got := (audioMsgConverter{}).Convert(&ConvertContext{RawContent: `{"duration":3500}`}); got != "[Voice: 4s]" { + t.Fatalf("audioMsgConverter.Convert() = %q", got) + } + if got := (videoMsgConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"file_2","file_name":"clip.mp4","duration":5000,"image_key":"img_cover"}`}); got != `