diff --git a/README.md b/README.md index 4902dcdf..56e4dba1 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ [中文版](./README.zh.md) | [English](./README.md) -The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/). +The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 26 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** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup -- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/) +- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 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 @@ -41,6 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t | ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances | | 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. | | 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) | +| 🔗 Apps | Develop, deploy HTML, web pages and applications | ## Installation & Quick Start diff --git a/README.zh.md b/README.zh.md index b9869090..82b55305 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,14 +6,14 @@ [中文版](./README.zh.md) | [English](./README.md) -飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。 +飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 26 个 AI Agent [Skills](./skills/)。 [安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献) ## 为什么选 lark-cli? -- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书 -- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/) +- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书 +- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/) - **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率 - **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用 - **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步 @@ -41,6 +41,7 @@ | ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 | | 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 | | 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) | +| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 | ## 安装与快速开始 diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index 189c4274..8d837004 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -125,5 +125,5 @@ func getLoginMsg(lang string) *loginMsg { // (not backed by from_meta service specs). Descriptions are now centralized in // service_descriptions.json. func getShortcutOnlyDomainNames() []string { - return []string{"base", "contact", "docs", "markdown"} + return []string{"base", "contact", "docs", "markdown", "apps"} } diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json index 35d7d43f..1d7abd2b 100644 --- a/internal/registry/service_descriptions.json +++ b/internal/registry/service_descriptions.json @@ -3,6 +3,10 @@ "en": { "title": "Approval", "description": "Approval instance, and task management" }, "zh": { "title": "审批", "description": "审批实例、审批任务管理" } }, + "apps": { + "en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" }, + "zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" } + }, "base": { "en": { "title": "Base", "description": "Table, field, record, view, dashboard, workflow, form, role & permission management" }, "zh": { "title": "多维表格", "description": "数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限管理" } diff --git a/shortcuts/apps/apps_access_scope_get.go b/shortcuts/apps/apps_access_scope_get.go new file mode 100644 index 00000000..5bb5382c --- /dev/null +++ b/shortcuts/apps/apps_access_scope_get.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsAccessScopeGet reads the current access scope configuration of a Miaoda app. +// 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。 +var AppsAccessScopeGet = common.Shortcut{ + Service: appsService, + Command: "+access-scope-get", + Description: "Get Miaoda app access scope configuration", + Risk: "read", + Scopes: []string{"spark:app.access_scope:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))). + Desc("Get Miaoda app access scope") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID)) + data, err := rctx.CallAPI("GET", path, nil, nil) + if err != nil { + return err + } + // 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。 + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "scope: %v\n", data["scope"]) + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_access_scope_get_test.go b/shortcuts/apps/apps_access_scope_get_test.go new file mode 100644 index 00000000..cb80494a --- /dev/null +++ b/shortcuts/apps/apps_access_scope_get_test.go @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsAccessScopeGet_Specific(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/access-scope", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "scope": "Range", + "users": []interface{}{"ou_x", "ou_y"}, + "departments": []interface{}{"od_z"}, + "chats": []interface{}{"oc_g"}, + "apply_config": map[string]interface{}{ + "enabled": true, + "approvers": []interface{}{"ou_appr"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAccessScopeGet, + []string{"+access-scope-get", "--app-id", "app_x", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"scope": "Range"`) { + t.Fatalf("scope string not preserved (expect raw \"Range\"): %s", got) + } + if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od_z"`) || !strings.Contains(got, `"oc_g"`) { + t.Fatalf("users/departments/chats fields missing in envelope: %s", got) + } + if !strings.Contains(got, `"ou_appr"`) { + t.Fatalf("apply_config.approvers missing: %s", got) + } +} + +func TestAppsAccessScopeGet_Public(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/access-scope", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"scope": "All", "require_login": false}, + }, + }) + + if err := runAppsShortcut(t, AppsAccessScopeGet, + []string{"+access-scope-get", "--app-id", "app_x", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"scope": "All"`) { + t.Fatalf("scope=All missing: %s", got) + } + if !strings.Contains(got, `"require_login": false`) { + t.Fatalf("require_login missing: %s", got) + } +} + +func TestAppsAccessScopeGet_Tenant(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/access-scope", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"scope": "Tenant"}, + }, + }) + + if err := runAppsShortcut(t, AppsAccessScopeGet, + []string{"+access-scope-get", "--app-id", "app_x", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), `"scope": "Tenant"`) { + t.Fatalf("scope=Tenant missing: %s", stdout.String()) + } +} + +func TestAppsAccessScopeGet_RequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAccessScopeGet, + []string{"+access-scope-get", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected --app-id required, got %v", err) + } +} + +func TestAppsAccessScopeGet_TrimsAppIDInPath(t *testing.T) { + // 与 +update 的 D1.2 修复对称:URL 拼接前必须 TrimSpace(app-id), + // 否则 " app_x " 会被 EncodePathSegment 编码进 path segment 出现空格转义。 + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/access-scope", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"scope": "Tenant"}, + }, + }) + + if err := runAppsShortcut(t, AppsAccessScopeGet, + []string{"+access-scope-get", "--app-id", " app_x ", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } +} diff --git a/shortcuts/apps/apps_access_scope_set.go b/shortcuts/apps/apps_access_scope_set.go new file mode 100644 index 00000000..1d41d1e9 --- /dev/null +++ b/shortcuts/apps/apps_access_scope_set.go @@ -0,0 +1,208 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var allowedAccessTargetTypes = map[string]bool{ + "user": true, + "department": true, + "chat": true, +} + +// AppsAccessScopeSet sets the app's access scope (specific / public / tenant). +var AppsAccessScopeSet = common.Shortcut{ + Service: appsService, + Command: "+access-scope-set", + Description: "Set Miaoda app access scope (specific / public / tenant)", + Risk: "write", + Scopes: []string{"spark:app.access_scope:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "scope", Desc: "scope: specific | public | tenant", Required: true, Enum: []string{"specific", "public", "tenant"}}, + {Name: "targets", Desc: `targets JSON array: [{"type":"user|department|chat","id":"..."}, ...]`}, + {Name: "apply-enabled", Type: "bool", Desc: "allow apply for access (scope=specific)"}, + {Name: "approver", Desc: "approver open_id (when --apply-enabled; server allows exactly one)"}, + {Name: "require-login", Type: "bool", Desc: "require login (scope=public)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + return validateAccessScopeFlags(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + dry := common.NewDryRunAPI(). + PUT(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))). + Desc("Set Miaoda app access scope") + body, bodyErr := buildAccessScopeBody(rctx) + if bodyErr != nil { + dry.Set("body_error", bodyErr.Error()) + } else { + dry.Body(body) + } + return dry + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + body, err := buildAccessScopeBody(rctx) + if err != nil { + return err + } + appID := strings.TrimSpace(rctx.Str("app-id")) + path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID)) + data, err := rctx.CallAPI("PUT", path, nil, body) + if err != nil { + return err + } + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope")) + }) + return nil + }, +} + +func validateAccessScopeFlags(rctx *common.RuntimeContext) error { + scope := rctx.Str("scope") + targets := strings.TrimSpace(rctx.Str("targets")) + applyEnabled := rctx.Bool("apply-enabled") + approver := strings.TrimSpace(rctx.Str("approver")) + requireLogin := rctx.Bool("require-login") + + switch scope { + case "specific": + if targets == "" { + return output.ErrValidation("--targets is required when --scope=specific") + } + if err := validateTargetsJSON(targets); err != nil { + return err + } + if approver != "" && !applyEnabled { + return output.ErrValidation("--approver requires --apply-enabled") + } + if requireLogin { + return output.ErrValidation("--require-login is not allowed when --scope=specific") + } + case "public": + if targets != "" { + return output.ErrValidation("--targets is not allowed when --scope=public") + } + if applyEnabled { + return output.ErrValidation("--apply-enabled is not allowed when --scope=public") + } + if approver != "" { + return output.ErrValidation("--approver is not allowed when --scope=public") + } + if !rctx.Cmd.Flags().Changed("require-login") { + return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)") + } + case "tenant": + if targets != "" || applyEnabled || approver != "" || requireLogin { + return output.ErrValidation("no extra flags allowed when --scope=tenant") + } + default: + return output.ErrValidation("--scope must be specific / public / tenant") + } + return nil +} + +func validateTargetsJSON(targetsJSON string) error { + var items []map[string]interface{} + if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil { + return output.ErrValidation("--targets is not valid JSON: %v", err) + } + if len(items) == 0 { + return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids") + } + for i, t := range items { + typ, _ := t["type"].(string) + if !allowedAccessTargetTypes[typ] { + return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ) + } + if id, _ := t["id"].(string); strings.TrimSpace(id) == "" { + return output.ErrValidation("--targets[%d].id is empty", i) + } + } + return nil +} + +// scopeStringToServerEnum 把 CLI 友好的 scope 字符串映射成后端字符串枚举。 +// CLI 用户 / Agent 仍然写 specific / public / tenant,body 里发后端枚举名。 +// 后端语义:All=互联网公开 / Tenant=组织内 / Range=部分人员。 +var scopeStringToServerEnum = map[string]string{ + "public": "All", + "tenant": "Tenant", + "specific": "Range", +} + +func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { + scope := rctx.Str("scope") + enum, ok := scopeStringToServerEnum[scope] + if !ok { + return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope) + } + body := map[string]interface{}{"scope": enum} + + switch scope { + case "specific": + // 用户传统一格式 [{type:user|department|chat, id:...}],body 里拆 3 个并列数组发后端。 + var targets []map[string]interface{} + if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil { + return nil, output.ErrValidation("--targets is not valid JSON: %v", err) + } + users, departments, chats := splitAccessScopeTargets(targets) + if len(users) > 0 { + body["users"] = users + } + if len(departments) > 0 { + body["departments"] = departments + } + if len(chats) > 0 { + body["chats"] = chats + } + if rctx.Bool("apply-enabled") { + applyConfig := map[string]interface{}{"enabled": true} + if approver := strings.TrimSpace(rctx.Str("approver")); approver != "" { + applyConfig["approvers"] = []string{approver} + } + body["apply_config"] = applyConfig + } + case "public": + body["require_login"] = rctx.Bool("require-login") + } + return body, nil +} + +// splitAccessScopeTargets 把统一 [{type,id}] 形态拆成后端要求的 users/departments/chats 三个数组。 +func splitAccessScopeTargets(targets []map[string]interface{}) (users, departments, chats []string) { + for _, t := range targets { + typ, _ := t["type"].(string) + id, _ := t["id"].(string) + id = strings.TrimSpace(id) + if id == "" { + continue + } + switch typ { + case "user": + users = append(users, id) + case "department": + departments = append(departments, id) + case "chat": + chats = append(chats, id) + } + } + return +} diff --git a/shortcuts/apps/apps_access_scope_set_test.go b/shortcuts/apps/apps_access_scope_set_test.go new file mode 100644 index 00000000..f5eede00 --- /dev/null +++ b/shortcuts/apps/apps_access_scope_set_test.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsAccessScopeSet_Specific(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/spark/v1/apps/app_x/access-scope", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", + "--app-id", "app_x", + "--scope", "specific", + "--targets", `[{"type":"user","id":"ou_xxx"},{"type":"chat","id":"oc_xxx"}]`, + "--apply-enabled", + "--approver", "ou_yyy", + "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + // 新协议:scope 是 string 枚举 (specific=Range),targets 拆成 users/departments/chats + if got, _ := sent["scope"].(string); got != "Range" { + t.Fatalf("scope = %v, want %q", sent["scope"], "Range") + } + if _, present := sent["targets"]; present { + t.Fatalf("legacy 'targets' field should not be sent: %v", sent) + } + users, _ := sent["users"].([]interface{}) + if len(users) != 1 || users[0] != "ou_xxx" { + t.Fatalf("users = %v, want [ou_xxx]", sent["users"]) + } + chats, _ := sent["chats"].([]interface{}) + if len(chats) != 1 || chats[0] != "oc_xxx" { + t.Fatalf("chats = %v, want [oc_xxx]", sent["chats"]) + } + if _, present := sent["departments"]; present { + t.Fatalf("departments should be omitted when empty: %v", sent) + } +} + +func TestAppsAccessScopeSet_Public(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/spark/v1/apps/app_x/access-scope", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + + if err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", + "--app-id", "app_x", + "--scope", "public", + "--require-login=false", + "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } +} + +func TestAppsAccessScopeSet_Tenant(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/spark/v1/apps/app_x/access-scope", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + + if err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", + "--app-id", "app_x", + "--scope", "tenant", + "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } +} + +func TestAppsAccessScopeSet_SpecificRequiresTargets(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", "--app-id", "app_x", "--scope", "specific", "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "targets") { + t.Fatalf("expected targets required error, got %v", err) + } +} + +func TestAppsAccessScopeSet_TenantRejectsExtraFlags(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", "--app-id", "app_x", "--scope", "tenant", + "--targets", `[]`, "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatalf("expected error when --targets passed with scope=tenant") + } +} + +func TestAppsAccessScopeSet_RejectsBadTargetType(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", "--app-id", "app_x", + "--scope", "specific", + "--targets", `[{"type":"group","id":"oc_xxx"}]`, + "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "type") { + t.Fatalf("expected bad target type rejected, got %v", err) + } +} + +func TestAppsAccessScopeSet_ApproverRequiresApplyEnabled(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", "--app-id", "app_x", + "--scope", "specific", + "--targets", `[{"type":"user","id":"ou_x"}]`, + "--approver", "ou_y", + "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "apply-enabled") { + t.Fatalf("expected --approver requires --apply-enabled, got %v", err) + } +} + +func TestAppsAccessScopeSet_PublicRejectsApprover(t *testing.T) { + // --approver 只在 specific + apply 流程下有意义;public 模式带它当前会被静默丢弃, + // 是真实用户语义 bug。这条测试钉死 Validate 阶段拦截。 + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", "--app-id", "app_x", + "--scope", "public", + "--require-login=false", + "--approver", "ou_y", + "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "--approver is not allowed when --scope=public") { + t.Fatalf("expected --approver rejected for scope=public, got %v", err) + } +} + +func TestAppsAccessScopeSet_PublicRequiresExplicitRequireLogin(t *testing.T) { + // bare --scope public without --require-login defaults silently to + // require_login=false (Internet-public + no auth). Reject so the caller + // has to make an explicit choice; matches SKILL.md "public 必传 --require-login". + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", "--app-id", "app_x", + "--scope", "public", + "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "--require-login is required when --scope=public") { + t.Fatalf("expected --require-login required for public, got %v", err) + } +} + +func TestAppsAccessScopeSet_SpecificRejectsEmptyTargets(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", "--app-id", "app_x", + "--scope", "specific", + "--targets", "[]", + "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "--targets must contain at least one entry") { + t.Fatalf("expected empty --targets rejected, got %v", err) + } +} + +func TestAppsAccessScopeSet_TrimsAppIDInPath(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/spark/v1/apps/app_x/access-scope", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + + if err := runAppsShortcut(t, AppsAccessScopeSet, []string{ + "+access-scope-set", "--app-id", " app_x ", + "--scope", "tenant", + "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } +} diff --git a/shortcuts/apps/apps_create.go b/shortcuts/apps/apps_create.go new file mode 100644 index 00000000..2436dd3c --- /dev/null +++ b/shortcuts/apps/apps_create.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsCreate creates a new Miaoda app. +var AppsCreate = common.Shortcut{ + Service: appsService, + Command: "+create", + Description: "Create a new Miaoda app", + Risk: "write", + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "name", Desc: "app display name", Required: true}, + {Name: "app-type", Desc: "app type (currently only: HTML)", Required: true}, + {Name: "description", Desc: "app description"}, + {Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("name")) == "" { + return output.ErrValidation("--name is required") + } + appType := strings.TrimSpace(rctx.Str("app-type")) + if appType == "" { + return output.ErrValidation("--app-type is required") + } + if !validAppTypes[appType] { + return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType)) + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST(apiBasePath + "/apps"). + Desc("Create a Miaoda app"). + Body(buildAppsCreateBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx)) + if err != nil { + return err + } + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app_id")) + }) + return nil + }, +} + +// 应用类型枚举。当前只有 HTML,未来会扩展(SPA、NATIVE、...)。 +var validAppTypes = map[string]bool{ + "HTML": true, +} + +func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{ + "name": strings.TrimSpace(rctx.Str("name")), + "app_type": strings.TrimSpace(rctx.Str("app-type")), + } + if desc := strings.TrimSpace(rctx.Str("description")); desc != "" { + body["description"] = desc + } + if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" { + body["icon_url"] = icon + } + return body +} diff --git a/shortcuts/apps/apps_create_test.go b/shortcuts/apps/apps_create_test.go new file mode 100644 index 00000000..3330d418 --- /dev/null +++ b/shortcuts/apps/apps_create_test.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "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" +) + +// 测试基础设施 —— 后续 Task 2.2-2.4 / Task 3.4 复用 + +func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + cfg := &core.CliConfig{ + AppID: "test-app-" + strings.ToLower(t.Name()), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_test", + } + factory, stdout, _, reg := cmdutil.TestFactory(t, cfg) + return factory, stdout, reg +} + +func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "apps"} + sc.Mount(parent, factory) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.ExecuteContext(context.Background()) +} + +// +create 测试 + +func TestAppsCreate_Success(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "app_id": "app_x", + "name": "Demo", + "icon_url": "https://lf3-static.bytednsdoc.com/.../default.svg", + "created_at": "2026-05-18T10:00:00Z", + }, + }, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "HTML", "--description", "d", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"app_id": "app_x"`) { + t.Fatalf("stdout missing app_id: %s", got) + } + + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["name"] != "Demo" { + t.Fatalf("body.name = %v", sent["name"]) + } + if sent["app_type"] != "HTML" { + t.Fatalf("body.app_type = %v (want HTML)", sent["app_type"]) + } + if sent["description"] != "d" { + t.Fatalf("body.description = %v", sent["description"]) + } + if _, present := sent["icon_url"]; present { + t.Fatalf("icon_url should be omitted when not provided: %v", sent) + } +} + +func TestAppsCreate_WithIconURL(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_id": "app_x", "name": "Demo"}, + }, + }) + + if err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "HTML", "--icon-url", "https://example.com/icon.svg", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } +} + +func TestAppsCreate_RequiresName(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "HTML", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "name") { + t.Fatalf("expected name required error, got %v", err) + } +} + +func TestAppsCreate_RequiresAppType(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "app-type") { + t.Fatalf("expected --app-type required error, got %v", err) + } +} + +func TestAppsCreate_RejectsInvalidAppType(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "spa", "--as", "user"}, + factory, stdout) + if err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("expected unsupported app-type error, got %v", err) + } +} + +func TestAppsCreate_DryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "HTML", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "/open-apis/spark/v1/apps") { + t.Fatalf("dry-run missing endpoint: %s", got) + } + if !strings.Contains(got, `"name": "Demo"`) { + t.Fatalf("dry-run missing body: %s", got) + } + if !strings.Contains(got, `"app_type": "HTML"`) { + t.Fatalf("dry-run missing app_type: %s", got) + } +} diff --git a/shortcuts/apps/apps_html_publish.go b/shortcuts/apps/apps_html_publish.go new file mode 100644 index 00000000..823ab527 --- /dev/null +++ b/shortcuts/apps/apps_html_publish.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsHTMLPublish packs --path as tar.gz and uploads + publishes via one multipart POST. +var AppsHTMLPublish = common.Shortcut{ + Service: appsService, + Command: "+html-publish", + Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)", + Risk: "write", + Scopes: []string{"spark:app:publish"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app ID", Required: true}, + {Name: "path", Desc: "path to HTML file or directory", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + path := strings.TrimSpace(rctx.Str("path")) + if path == "" { + return output.ErrValidation("--path is required") + } + // Reject --path equal to the current working directory. Publishing + // cwd recursively packs .git/ / .env / node_modules / .aws/credentials + // alongside the intended HTML, and combined with --scope public puts + // those on an internet-reachable URL. + if filepath.Clean(path) == "." { + return output.ErrWithHint(output.ExitValidation, "validation", + "--path 不能指向当前工作目录(避免误把整个工程一并发布出去)", + "改成具体的子目录或文件,如 './dist' / './public' / './index.html'") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + path := strings.TrimSpace(rctx.Str("path")) + dry := common.NewDryRunAPI() + dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)") + dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))). + Set("content_type", "multipart/form-data") + + candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path) + if err != nil { + dry.Set("path_error", err.Error()) + return dry + } + if err := ensureIndexHTML(candidates); err != nil { + // Surface the same failure Execute would hit, but as a structured + // envelope field so dry-run still exits 0 (matches repo convention + // for dry-run "advisory preview" semantics). + dry.Set("validation_error", err.Error()) + } + dry.Set("file_count", len(candidates)) + var totalSize int64 + names := make([]string, 0, len(candidates)) + for _, c := range candidates { + totalSize += c.Size + names = append(names, c.RelPath) + } + dry.Set("total_size_bytes", totalSize) + dry.Set("files", names) + // Advisory scan: surface paths matching well-known secret / credential + // patterns so the caller can review before going public. Dry-run still + // exits 0; this is non-blocking by design (legit doc sites may ship + // example .env files). + var warnings []string + for _, c := range candidates { + if isSensitiveRelPath(c.RelPath) { + warnings = append(warnings, c.RelPath) + } + } + if len(warnings) > 0 { + dry.Set("warnings", warnings) + dry.Set("warning_summary", fmt.Sprintf("manifest contains %d sensitive path(s); review before publishing", len(warnings))) + } + return dry + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + spec := appsHTMLPublishSpec{ + AppID: strings.TrimSpace(rctx.Str("app-id")), + Path: strings.TrimSpace(rctx.Str("path")), + } + client := appsHTMLPublishAPI{runtime: rctx} + out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec) + if err != nil { + return err + } + rctx.OutFormat(out, nil, func(w io.Writer) { + if url, ok := out["url"].(string); ok && url != "" { + fmt.Fprintf(w, "url: %s\n", url) + } + }) + return nil + }, +} + +type appsHTMLPublishSpec struct { + AppID string + Path string +} + +// maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。 +// 用 var 而非 const,便于单测调小覆盖拦截路径。 +var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024 + +// maxHTMLPublishRawBytes caps the total UNCOMPRESSED candidate size before +// tar+gzip writes them into the in-memory buffer. Defends against +// highly-compressible "decompression bomb" inputs (e.g. 50GB of zeros) +// that would balloon process memory before the gzip-after check fires. +// 200MB is much higher than any plausible legitimate HTML/static-site +// payload but low enough to stay well under typical container memory. +// Mutable for tests. +var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024 + +// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。 +// 目录形态:根目录下必须有 index.html。 +// 单文件形态:文件名必须就是 index.html。 +// 妙搭服务端用 index.html 作为应用入口。 +func ensureIndexHTML(candidates []htmlPublishCandidate) error { + for _, c := range candidates { + if c.RelPath == "index.html" { + return nil + } + } + return output.ErrWithHint(output.ExitAPI, "validation", + "--path 中缺少 index.html", + "妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html,单文件形态把文件命名为 index.html") +} + +func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) { + // Defense in depth: callers reaching runHTMLPublish bypass the shortcut's + // Validate closure. Re-check that --path is not cwd before walking. + if filepath.Clean(spec.Path) == "." { + return nil, output.ErrWithHint(output.ExitValidation, "validation", + "--path 不能指向当前工作目录(避免误把整个工程一并发布出去)", + "改成具体的子目录或文件,如 './dist' / './public' / './index.html'") + } + candidates, err := walkHTMLPublishCandidates(fio, spec.Path) + if err != nil { + return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err) + } + if err := ensureIndexHTML(candidates); err != nil { + return nil, err + } + var rawTotal int64 + for _, c := range candidates { + rawTotal += c.Size + } + if rawTotal > maxHTMLPublishRawBytes { + return nil, output.ErrWithHint(output.ExitAPI, "validation", + fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes), + "在 tar+gzip 进入内存前拦截,避免 OOM;精简 --path 内容或选择更小的子目录") + } + tarball, err := buildHTMLPublishTarball(fio, candidates) + if err != nil { + return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err) + } + + if tarball.Size > maxHTMLPublishTarballBytes { + return nil, output.ErrWithHint(output.ExitAPI, "validation", + fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes), + "请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB") + } + + resp, err := client.HTMLPublish(ctx, spec.AppID, tarball) + if err != nil { + return nil, err + } + + out := map[string]interface{}{} + if resp.URL != "" { + out["url"] = resp.URL + } + return out, nil +} diff --git a/shortcuts/apps/apps_html_publish_test.go b/shortcuts/apps/apps_html_publish_test.go new file mode 100644 index 00000000..59240284 --- /dev/null +++ b/shortcuts/apps/apps_html_publish_test.go @@ -0,0 +1,338 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/output" +) + +type fakeAppsHTMLPublishClient struct { + resp *htmlPublishResponse + err error + calls []string +} + +func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) { + f.calls = append(f.calls, appID) + if f.err != nil { + return nil, f.err + } + return f.resp, nil +} + +func writeAppsSampleSite(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + return dir +} + +func TestRunHTMLPublish_HappyPath(t *testing.T) { + site := writeAppsSampleSite(t) + fake := &fakeAppsHTMLPublishClient{ + resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}, + } + out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site}) + if err != nil { + t.Fatalf("err=%v", err) + } + if out["url"] != "https://miaoda/app_x" { + t.Fatalf("url=%v", out["url"]) + } + if len(fake.calls) != 1 || fake.calls[0] != "app_x" { + t.Fatalf("calls=%v", fake.calls) + } +} + +func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) { + // Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步": + // envelope 只含 url,未来若有人加 status / release_id 字段会被这个测试拦截。 + site := writeAppsSampleSite(t) + fake := &fakeAppsHTMLPublishClient{ + resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}, + } + out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site}) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(out) != 1 { + t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out) + } + if _, ok := out["url"]; !ok { + t.Fatalf("envelope missing 'url': %v", out) + } +} + +func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) { + site := writeAppsSampleSite(t) + wantErr := errors.New("server timeout") + fake := &fakeAppsHTMLPublishClient{err: wantErr} + _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site}) + if !errors.Is(err, wantErr) { + t.Fatalf("err=%v", err) + } +} + +func TestRunHTMLPublish_PathNotFound(t *testing.T) { + fake := &fakeAppsHTMLPublishClient{} + _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"}) + if err == nil { + t.Fatalf("expected error") + } + if len(fake.calls) != 0 { + t.Fatalf("client should not be called when path invalid") + } +} + +func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) { + // 目录形态:缺 index.html 应该被拦 + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + fake := &fakeAppsHTMLPublishClient{} + _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}) + if err == nil { + t.Fatalf("expected error for missing index.html") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected ExitError with detail, got %v", err) + } + if exitErr.Detail.Type != "validation" { + t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, "index.html") { + t.Fatalf("message missing 'index.html': %v", exitErr.Detail.Message) + } + if exitErr.Detail.Hint == "" { + t.Fatalf("expected non-empty hint") + } + if len(fake.calls) != 0 { + t.Fatalf("client should not be called when index.html missing") + } +} + +func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) { + // 目录含 index.html 应该正常走完 + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}} + if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil { + t.Fatalf("err=%v", err) + } + if len(fake.calls) != 1 { + t.Fatalf("client should be called when index.html present") + } +} + +func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) { + // 单文件形态:文件名不是 index.html 也要拦 + dir := t.TempDir() + single := filepath.Join(dir, "foo.html") + if err := os.WriteFile(single, []byte(""), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + fake := &fakeAppsHTMLPublishClient{} + _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}) + if err == nil { + t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" { + t.Fatalf("expected ExitError type=validation, got %v", err) + } + if len(fake.calls) != 0 { + t.Fatalf("client must not be called when index.html missing") + } +} + +func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) { + // 单文件形态:文件名恰好就是 index.html → 放行 + dir := t.TempDir() + single := filepath.Join(dir, "index.html") + if err := os.WriteFile(single, []byte(""), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}} + if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil { + t.Fatalf("err=%v", err) + } + if len(fake.calls) != 1 { + t.Fatalf("client should be called for single index.html") + } +} + +func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) { + // 把上限调到 100 字节验证拦截,defer 恢复原值避免污染其它测试。 + orig := maxHTMLPublishTarballBytes + maxHTMLPublishTarballBytes = 100 + defer func() { maxHTMLPublishTarballBytes = orig }() + + dir := t.TempDir() + // 写 index.html(满足新加的 index 校验)+ 大文件超 100 字节上限。 + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "big.html"), + []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + fake := &fakeAppsHTMLPublishClient{} + _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}) + if err == nil { + t.Fatalf("expected oversize error") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected ExitError with detail, got %v", err) + } + if exitErr.Detail.Type != "validation" { + t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, "exceeds") { + t.Fatalf("message missing 'exceeds': %v", exitErr.Detail.Message) + } + if exitErr.Detail.Hint == "" { + t.Fatalf("expected non-empty hint") + } + if len(fake.calls) != 0 { + t.Fatalf("client should not be called when tarball oversize") + } +} + +func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) { + // Pin 20MB 常量值,typo 到 20*1000*1024 之类会被拦截。 + if maxHTMLPublishTarballBytes != 20*1024*1024 { + t.Fatalf("default = %d, want %d (20MiB)", maxHTMLPublishTarballBytes, 20*1024*1024) + } +} + +func TestAppsHTMLPublish_RequiresAppID(t *testing.T) { + site := writeAppsSampleSite(t) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsHTMLPublish, + []string{"+html-publish", "--path", site}, factory, stdout) + // cobra Required:true may report flag name without "--" prefix + if err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected --app-id required, got %v", err) + } +} + +func TestAppsHTMLPublish_RequiresPath(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsHTMLPublish, + []string{"+html-publish", "--app-id", "app_x"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "path") { + t.Fatalf("expected --path required, got %v", err) + } +} + +func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) { + // 这个用例走真实 shortcut → 真实 LocalFileIO(cwd-bounded)。 + // 必须 chdir 进 tmp 用相对路径,否则 SafeInputPath 会拒绝绝对 --path。 + // --path "." 被 Validate 拒绝,因此改为在 tmp 下建 dist 子目录并传 ./dist。 + dir := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.MkdirAll(filepath.Join(dir, "dist"), 0o755); err != nil { + t.Fatalf("mkdir dist: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsHTMLPublish, + []string{"+html-publish", "--app-id", "app_x", "--path", "./dist", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") { + t.Fatalf("dry-run missing endpoint: %s", got) + } + if !strings.Contains(got, "index.html") { + t.Fatalf("dry-run missing file list: %s", got) + } +} + +func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) { + orig := maxHTMLPublishRawBytes + maxHTMLPublishRawBytes = 100 + defer func() { maxHTMLPublishRawBytes = orig }() + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + fake := &fakeAppsHTMLPublishClient{} + _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, + appsHTMLPublishSpec{AppID: "app_x", Path: dir}) + if err == nil { + t.Fatalf("expected raw-size cap to fire") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected ExitError with detail, got %v", err) + } + if exitErr.Detail.Type != "validation" { + t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, "raw") || !strings.Contains(exitErr.Detail.Message, "bytes") { + t.Fatalf("expected message to explain raw-byte cap, got %q", exitErr.Detail.Message) + } + if len(fake.calls) != 0 { + t.Fatalf("client must not be called when raw cap hit") + } +} + +func TestRunHTMLPublish_RejectsCurrentDirectoryPath(t *testing.T) { + // Publishing the entire current working directory is the canonical + // secrets-exfiltration footgun (.git/.env/node_modules all end up in the + // tarball). Reject --path "." (and Clean equivalents) at runHTMLPublish + // entry so any direct caller cannot accidentally trigger it. (Validate + // also rejects at flag layer; this is defense in depth.) + fake := &fakeAppsHTMLPublishClient{} + _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, + appsHTMLPublishSpec{AppID: "app_x", Path: "."}) + if err == nil { + t.Fatalf("expected --path '.' to be rejected") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" { + t.Fatalf("expected ExitError type=validation, got %v", err) + } + if !strings.Contains(exitErr.Detail.Message, "当前工作目录") { + t.Fatalf("error message should explain cwd is forbidden, got %q", exitErr.Detail.Message) + } + if len(fake.calls) != 0 { + t.Fatalf("client must not be called when --path is cwd") + } +} diff --git a/shortcuts/apps/apps_list.go b/shortcuts/apps/apps_list.go new file mode 100644 index 00000000..edfcca54 --- /dev/null +++ b/shortcuts/apps/apps_list.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsList lists Miaoda apps owned by the calling user (cursor pagination). +// +// Hidden from --help / tab completion (Hidden: true) so agents do not discover it +// as a way to enumerate / search applications. Direct invocation still works for +// humans who know the command. When agents need an existing app_id, they should +// ask the user to provide either the Miaoda app URL (extract app_id from the +// path segment after /app/) or the app_id string directly; see lark-apps SKILL.md. +var AppsList = common.Shortcut{ + Service: appsService, + Command: "+list", + Description: "List Miaoda apps owned by the calling user (cursor pagination)", + Risk: "read", + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Hidden: true, + Flags: []common.Flag{ + {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "pagination cursor from previous response"}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET(apiBasePath + "/apps"). + Desc("List Miaoda apps"). + Params(buildAppsListParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPI("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil) + if err != nil { + return err + } + items, _ := data["items"].([]interface{}) + rctx.OutFormat(data, nil, func(w io.Writer) { + // Table view (--format table) intentionally shows only the columns + // most useful for visual scanning: app_id (to copy-paste downstream), + // name (to match what the user sees in the UI), and updated_at (to + // pick the most recent variant). description / icon_url / created_at + // stay in the underlying JSON (--format json) but would make the + // table too wide for a terminal. + rows := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, map[string]interface{}{ + "app_id": m["app_id"], + "name": m["name"], + "updated_at": m["updated_at"], + }) + } + output.PrintTable(w, rows) + }) + return nil + }, +} + +func buildAppsListParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "page_size": rctx.Int("page-size"), + } + if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { + params["page_token"] = token + } + return params +} diff --git a/shortcuts/apps/apps_list_test.go b/shortcuts/apps/apps_list_test.go new file mode 100644 index 00000000..3b34ec21 --- /dev/null +++ b/shortcuts/apps/apps_list_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsList_FirstPage(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps?page_size=20", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"app_id": "app_a", "name": "Alpha", "updated_at": "2026-05-18T10:00:00Z"}, + map[string]interface{}{"app_id": "app_b", "name": "Beta", "updated_at": "2026-05-18T09:00:00Z"}, + }, + "page_token": "next_cursor", + "has_more": true, + }, + }, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsList, []string{"+list", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "app_a") || !strings.Contains(got, "app_b") { + t.Fatalf("output missing items: %s", got) + } + if !strings.Contains(got, "Alpha") || !strings.Contains(got, "Beta") { + t.Fatalf("output missing item names: %s", got) + } +} + +func TestAppsList_WithPageToken(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps?page_size=50&page_token=cursor_abc", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{}, + "has_more": false, + }, + }, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsList, + []string{"+list", "--page-size", "50", "--page-token", "cursor_abc", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } +} + +func TestAppsList_DryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsList, + []string{"+list", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "/open-apis/spark/v1/apps") { + t.Fatalf("dry-run missing endpoint: %s", got) + } + if !strings.Contains(got, "page_size") { + t.Fatalf("dry-run missing page_size param: %s", got) + } +} diff --git a/shortcuts/apps/apps_update.go b/shortcuts/apps/apps_update.go new file mode 100644 index 00000000..f1d3d90a --- /dev/null +++ b/shortcuts/apps/apps_update.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsUpdate partially updates a Miaoda app's name / description. +var AppsUpdate = common.Shortcut{ + Service: appsService, + Command: "+update", + Description: "Partially update a Miaoda app (only provided fields are sent)", + Risk: "write", + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "name", Desc: "new app display name"}, + {Name: "description", Desc: "new app description"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + body := buildAppsUpdateBody(rctx) + if len(body) == 0 { + return output.ErrValidation("provide at least one of --name or --description") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + return common.NewDryRunAPI(). + PATCH(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))). + Desc("Update a Miaoda app"). + Body(buildAppsUpdateBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID)) + data, err := rctx.CallAPI("PATCH", path, nil, buildAppsUpdateBody(rctx)) + if err != nil { + return err + } + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "updated: %s\n", common.GetString(data, "app_id")) + }) + return nil + }, +} + +func buildAppsUpdateBody(rctx *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{} + if v := strings.TrimSpace(rctx.Str("name")); v != "" { + body["name"] = v + } + if v := strings.TrimSpace(rctx.Str("description")); v != "" { + body["description"] = v + } + return body +} diff --git a/shortcuts/apps/apps_update_test.go b/shortcuts/apps/apps_update_test.go new file mode 100644 index 00000000..7c9e26ec --- /dev/null +++ b/shortcuts/apps/apps_update_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsUpdate_PartialFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/spark/v1/apps/app_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "app_id": "app_x", + "name": "renamed", + "updated_at": "2026-05-18T10:05:00Z", + }, + }, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsUpdate, + []string{"+update", "--app-id", "app_x", "--name", "renamed", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["name"] != "renamed" { + t.Fatalf("body.name = %v", sent["name"]) + } + if _, present := sent["description"]; present { + t.Fatalf("description should not be in body when not provided: %v", sent) + } +} + +func TestAppsUpdate_RequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsUpdate, + []string{"+update", "--name", "renamed", "--as", "user"}, factory, stdout) + // cobra Required:true may match "app-id" instead of "--app-id" + if err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected --app-id required, got %v", err) + } +} + +func TestAppsUpdate_RequiresAtLeastOneField(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsUpdate, + []string{"+update", "--app-id", "app_x", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected error when no field provided") + } +} + +func TestAppsUpdate_TrimsAppIDInPath(t *testing.T) { + // 钉死 --app-id 在拼进 URL 前要先 TrimSpace —— 与 create / access-scope-* 等保持一致, + // 避免 " app_x " 这种取值被原样 EncodePathSegment 编进 path 出现空格转义。 + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/spark/v1/apps/app_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_id": "app_x"}, + }, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsUpdate, + []string{"+update", "--app-id", " app_x ", "--name", "renamed", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } +} diff --git a/shortcuts/apps/common.go b/shortcuts/apps/common.go new file mode 100644 index 00000000..7616e14d --- /dev/null +++ b/shortcuts/apps/common.go @@ -0,0 +1,10 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +// appsService 是 CLI 命令的 service 前缀(lark-cli apps ...)。 +const appsService = "apps" + +// apiBasePath is the registered OAPI prefix for the Miaoda apps domain. +const apiBasePath = "/open-apis/spark/v1" diff --git a/shortcuts/apps/html_publish_client.go b/shortcuts/apps/html_publish_client.go new file mode 100644 index 00000000..c1b05b93 --- /dev/null +++ b/shortcuts/apps/html_publish_client.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + 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" +) + +type htmlPublishResponse struct { + URL string +} + +type appsHTMLPublishClient interface { + HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) +} + +type appsHTMLPublishAPI struct { + runtime *common.RuntimeContext +} + +func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) { + fd := larkcore.NewFormdata() + fd.AddFile("file", bytes.NewReader(tarball.Body)) + + apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)), + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + return nil, err + } + return parseHTMLPublishResponse(apiResp.RawBody) +} + +func parseHTMLPublishResponse(raw []byte) (*htmlPublishResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + URL string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, fmt.Errorf("decode html-publish response: %w", err) + } + if envelope.Code != 0 { + return nil, output.ErrWithHint(output.ExitAPI, "api_error", + fmt.Sprintf("html-publish failed (code=%d): %s", envelope.Code, envelope.Msg), + buildHTMLPublishFailureHint(envelope.Code)) + } + return &htmlPublishResponse{URL: envelope.Data.URL}, nil +} + +// OAPI business error codes returned by the Miaoda +// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend +// service; update when new codes are documented in the OAPI spec. +const ( + errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed + errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission +) + +func buildHTMLPublishFailureHint(code int) string { + switch code { + case errCodeBuildFailed: + return "构建失败:用 `lark-cli apps +html-publish --app-id --path --dry-run` 检查打包文件清单" + case errCodeAppNotFound: + return "应用不存在或无权访问;请用户确认 app_id(从妙搭应用链接 https://miaoda.feishu.cn/app/app_xxx 的 /app/ 后面提取,或直接给 app_xxx 字符串)" + default: + return "" + } +} diff --git a/shortcuts/apps/html_publish_client_test.go b/shortcuts/apps/html_publish_client_test.go new file mode 100644 index 00000000..ca0a5949 --- /dev/null +++ b/shortcuts/apps/html_publish_client_test.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "errors" + "mime" + "mime/multipart" + "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/larksuite/cli/shortcuts/common" +) + +func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{ + AppID: "test-app-" + strings.ToLower(t.Name()), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_test", + } + factory, _, _, reg := cmdutil.TestFactory(t, cfg) + rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser) + return rctx, reg +} + +func TestAppsHTMLPublishAPI_Success(t *testing.T) { + rctx, reg := newAppsClientRuntime(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "url": "https://miaoda.feishu.cn/app/app_x", + }, + }, + } + reg.Register(stub) + + api := appsHTMLPublishAPI{runtime: rctx} + tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"} + resp, err := api.HTMLPublish(context.Background(), "app_x", tarball) + if err != nil { + t.Fatalf("err=%v", err) + } + if resp.URL != "https://miaoda.feishu.cn/app/app_x" { + t.Fatalf("url=%q", resp.URL) + } + + ct := stub.CapturedHeaders.Get("Content-Type") + mt, params, err := mime.ParseMediaType(ct) + if err != nil || mt != "multipart/form-data" { + t.Fatalf("content type %q wrong", ct) + } + mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) + saw := false + for { + p, err := mr.NextPart() + if err != nil { + break + } + if p.FormName() == "file" { + saw = true + } + } + if !saw { + t.Fatalf("multipart missing 'file' part") + } +} + +func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) { + rctx, reg := newAppsClientRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code", + Body: map[string]interface{}{ + "code": 90001, + "msg": "build failed: dependency conflict", + }, + }) + + api := appsHTMLPublishAPI{runtime: rctx} + _, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")}) + if err == nil { + t.Fatalf("expected error") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected ExitError with detail, got %v", err) + } + if exitErr.Detail.Hint == "" { + t.Fatalf("expected non-empty hint on code 90001") + } + if !strings.Contains(exitErr.Detail.Message, "build failed") { + t.Fatalf("missing failure message: %v", exitErr.Detail.Message) + } +} + +func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) { + // 默认分支:未识别的 code 返回空 hint,让 Agent 用 message 兜底。 + if hint := buildHTMLPublishFailureHint(99999); hint != "" { + t.Fatalf("unknown code should return empty hint, got %q", hint) + } + if hint := buildHTMLPublishFailureHint(0); hint != "" { + t.Fatalf("zero code should return empty hint, got %q", hint) + } +} + +func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) { + if hint := buildHTMLPublishFailureHint(90001); hint == "" { + t.Fatalf("code 90001 should return non-empty hint") + } + if hint := buildHTMLPublishFailureHint(90002); hint == "" { + t.Fatalf("code 90002 should return non-empty hint") + } +} + +func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) { + hint := buildHTMLPublishFailureHint(90002) + if hint == "" { + t.Fatalf("code 90002 should return non-empty hint") + } + if strings.Contains(hint, "+list") { + t.Fatalf("hint must not point at hidden +list command, got: %q", hint) + } + if !strings.Contains(hint, "app_id") { + t.Fatalf("hint should reference app_id, got: %q", hint) + } +} diff --git a/shortcuts/apps/html_publish_tarball.go b/shortcuts/apps/html_publish_tarball.go new file mode 100644 index 00000000..b49d4eb7 --- /dev/null +++ b/shortcuts/apps/html_publish_tarball.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + + "github.com/larksuite/cli/extension/fileio" +) + +// htmlPublishTarball is the in-memory packed tar.gz ready for multipart upload. +// Body is bounded by maxHTMLPublishTarballBytes (20MiB) — see runHTMLPublish. +type htmlPublishTarball struct { + Body []byte + Size int64 + SHA256 string +} + +func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishTarball, error) { + if len(candidates) == 0 { + return nil, errors.New("no files to pack") + } + + var buf bytes.Buffer + hasher := sha256.New() + multi := io.MultiWriter(&buf, hasher) + gz := gzip.NewWriter(multi) + tw := tar.NewWriter(gz) + + for _, c := range candidates { + if err := writeHTMLPublishTarEntry(fio, tw, c); err != nil { + _ = tw.Close() + _ = gz.Close() + return nil, err + } + } + + if err := tw.Close(); err != nil { + _ = gz.Close() + return nil, fmt.Errorf("tar close: %w", err) + } + if err := gz.Close(); err != nil { + return nil, fmt.Errorf("gzip close: %w", err) + } + + return &htmlPublishTarball{ + Body: buf.Bytes(), + Size: int64(buf.Len()), + SHA256: hex.EncodeToString(hasher.Sum(nil)), + }, nil +} + +func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCandidate) error { + if isUnsafeRelPath(c.RelPath) { + return fmt.Errorf("invalid tar entry name %q", c.RelPath) + } + + src, err := fio.Open(c.AbsPath) + if err != nil { + return fmt.Errorf("open %s: %w", c.AbsPath, err) + } + defer src.Close() + + hdr := &tar.Header{ + Name: c.RelPath, + Size: c.Size, + Mode: 0o644, + Typeflag: tar.TypeReg, + } + if err := tw.WriteHeader(hdr); err != nil { + return fmt.Errorf("write header %s: %w", c.RelPath, err) + } + if _, err := io.Copy(tw, src); err != nil { + return fmt.Errorf("copy %s: %w", c.RelPath, err) + } + return nil +} diff --git a/shortcuts/apps/html_publish_tarball_test.go b/shortcuts/apps/html_publish_tarball_test.go new file mode 100644 index 00000000..d5c14390 --- /dev/null +++ b/shortcuts/apps/html_publish_tarball_test.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/extension/fileio" +) + +// readFailingFIO opens a File whose Read always returns the configured error, +// letting tests exercise the io.Copy failure branch without filesystem games. +type readFailingFIO struct{ readErr error } + +func (f readFailingFIO) Open(string) (fileio.File, error) { + return &readFailingFile{err: f.readErr}, nil +} +func (f readFailingFIO) Stat(string) (fileio.FileInfo, error) { + return nil, errors.New("Stat not used") +} +func (readFailingFIO) ResolvePath(p string) (string, error) { return p, nil } +func (readFailingFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { + return nil, errors.New("Save not used") +} + +type readFailingFile struct{ err error } + +func (f *readFailingFile) Read([]byte) (int, error) { return 0, f.err } +func (f *readFailingFile) ReadAt([]byte, int64) (int, error) { return 0, f.err } +func (f *readFailingFile) Close() error { return nil } + +func TestBuildHTMLPublishTarball_RoundTrip(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + fio := newTestFIO() + candidates, err := walkHTMLPublishCandidates(fio, dir) + if err != nil { + t.Fatalf("walk: %v", err) + } + tarball, err := buildHTMLPublishTarball(fio, candidates) + if err != nil { + t.Fatalf("build: %v", err) + } + + if len(tarball.SHA256) != 64 { + t.Fatalf("SHA256 wrong len: %d", len(tarball.SHA256)) + } + if tarball.Size <= 0 || int64(len(tarball.Body)) != tarball.Size { + t.Fatalf("size=%d body=%d", tarball.Size, len(tarball.Body)) + } + + gz, err := gzip.NewReader(bytes.NewReader(tarball.Body)) + if err != nil { + t.Fatalf("gzip: %v", err) + } + tr := tar.NewReader(gz) + hdr, err := tr.Next() + if err != nil { + t.Fatalf("tar.Next: %v", err) + } + if hdr.Name != "index.html" { + t.Fatalf("entry name = %q, want index.html", hdr.Name) + } + body, err := io.ReadAll(tr) + if err != nil || string(body) != "" { + t.Fatalf("body=%q err=%v", body, err) + } +} + +func TestBuildHTMLPublishTarball_EmptyCandidates(t *testing.T) { + if _, err := buildHTMLPublishTarball(newTestFIO(), nil); err == nil { + t.Fatalf("expected error") + } +} + +func TestWriteHTMLPublishTarEntry_OpenFailure(t *testing.T) { + // candidate 指向不存在文件 → fio.Open 失败 → 错误返回 + tw := tar.NewWriter(io.Discard) + defer tw.Close() + err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{ + RelPath: "x.html", + AbsPath: "/nonexistent-path-for-test/x.html", + Size: 0, + }) + if err == nil { + t.Fatalf("expected error for nonexistent abs path") + } + if !strings.Contains(err.Error(), "open") { + t.Fatalf("expected open error, got %v", err) + } +} + +func TestWriteHTMLPublishTarEntry_WriteHeaderFailure(t *testing.T) { + // 在已 close 的 tar.Writer 上写 header → WriteHeader 失败 + dir := t.TempDir() + file := filepath.Join(dir, "x.html") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + tw := tar.NewWriter(io.Discard) + _ = tw.Close() // 先 close,下次 WriteHeader 必失败 + + err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{ + RelPath: "x.html", + AbsPath: file, + Size: 1, + }) + if err == nil { + t.Fatalf("expected error when writing to closed tar.Writer") + } + if !strings.Contains(err.Error(), "write header") { + t.Fatalf("expected 'write header' error, got %v", err) + } +} + +func TestWriteHTMLPublishTarEntry_CopyFailure(t *testing.T) { + // 注入一个 Read 必失败的 fileio.File,让 io.Copy 在 tar 写入阶段出错。 + // 避免 chmod 0o000 的跨平台 / root 用户 flake。 + fio := readFailingFIO{readErr: errors.New("synthetic read failure")} + tw := tar.NewWriter(io.Discard) + defer tw.Close() + + err := writeHTMLPublishTarEntry(fio, tw, htmlPublishCandidate{ + RelPath: "x.html", + AbsPath: "fixtures/x.html", // 任意路径,Open 由 stub 接管 + Size: 7, + }) + if err == nil { + t.Fatalf("expected error when underlying Read fails") + } + if !strings.Contains(err.Error(), "copy") { + t.Fatalf("expected copy-stage error, got %v", err) + } +} + +func TestBuildHTMLPublishTarball_EntryWriteFailureReturnsError(t *testing.T) { + // candidate 指向不存在文件 → writeHTMLPublishTarEntry 失败 + // → buildHTMLPublishTarball 返回 nil tarball + error。 + candidates := []htmlPublishCandidate{ + {RelPath: "x.html", AbsPath: "/nonexistent-path-for-test/x.html", Size: 0}, + } + + tarball, err := buildHTMLPublishTarball(newTestFIO(), candidates) + if err == nil { + t.Fatalf("expected error, got tarball=%+v", tarball) + } + if tarball != nil { + t.Fatalf("expected nil tarball on error, got %+v", tarball) + } +} + +func TestWriteHTMLPublishTarEntry_RejectsPathTraversal(t *testing.T) { + tw := tar.NewWriter(io.Discard) + defer tw.Close() + + cases := []struct { + name string + rel string + }{ + {"parent traversal", "../etc/passwd"}, + {"absolute path", "/etc/passwd"}, + {"embedded traversal", "a/../../etc/passwd"}, + {"null byte", "evil\x00.html"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{ + RelPath: c.rel, + AbsPath: "fixtures/whatever", + Size: 0, + }) + if err == nil { + t.Fatalf("expected error for RelPath=%q", c.rel) + } + if !strings.Contains(err.Error(), "invalid tar entry name") { + t.Fatalf("expected 'invalid tar entry name' error, got %v", err) + } + }) + } +} diff --git a/shortcuts/apps/sensitive_paths.go b/shortcuts/apps/sensitive_paths.go new file mode 100644 index 00000000..f95469b9 --- /dev/null +++ b/shortcuts/apps/sensitive_paths.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import "strings" + +// isSensitiveRelPath reports whether a relative path inside the candidate +// manifest looks like something that should not ship to a public-internet +// share URL — secrets, credentials, SCM internals, SSH keys. The check is +// path-element-wise (each "/"-delimited segment is inspected) so secrets +// nested under arbitrary subdirectories are still caught. +// +// Used by +html-publish dry-run to populate a "warnings" field; the +// caller still proceeds (this is advisory, not a hard block) so legit +// edge cases (e.g. a documentation site that has a .env example file +// on purpose) are not gated, but the user/agent sees the list. +func isSensitiveRelPath(rel string) bool { + if rel == "" { + return false + } + parts := strings.Split(rel, "/") + for i, p := range parts { + switch { + case p == ".git": + return true + case p == ".env" || strings.HasPrefix(p, ".env."): + return true + case p == ".npmrc" || p == ".netrc": + return true + case p == "credentials" || p == "config": + if i > 0 { + parent := parts[i-1] + if parent == ".aws" || parent == ".docker" || parent == ".gcloud" || parent == ".kube" { + return true + } + } + case strings.HasPrefix(p, "id_rsa") || strings.HasPrefix(p, "id_ed25519") || strings.HasPrefix(p, "id_ecdsa") || strings.HasPrefix(p, "id_dsa"): + return true + case strings.HasSuffix(p, ".pem") || strings.HasSuffix(p, ".key"): + return true + case strings.HasSuffix(p, ".json") && p == "config.json" && i > 0 && parts[i-1] == ".docker": + return true + } + } + return false +} diff --git a/shortcuts/apps/sensitive_paths_test.go b/shortcuts/apps/sensitive_paths_test.go new file mode 100644 index 00000000..7afeb69a --- /dev/null +++ b/shortcuts/apps/sensitive_paths_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import "testing" + +func TestIsSensitiveRelPath(t *testing.T) { + cases := []struct { + rel string + want bool + }{ + // dotfiles and well-known secret stores + {".env", true}, + {".env.local", true}, + {".env.production", true}, + {"backend/.env", true}, + {".npmrc", true}, + {"sub/.npmrc", true}, + {".netrc", true}, + // .git tree + {".git/config", true}, + {".git/HEAD", true}, + {"subdir/.git/config", true}, + {".gitignore", false}, // NOT sensitive (intended to be committed) + // SSH keys + {".ssh/id_rsa", true}, + {".ssh/id_ed25519", true}, + {"backup/id_rsa.pub", true}, // pub also flagged (often near private) + // Cloud creds + {".aws/credentials", true}, + {".aws/config", true}, + {".docker/config.json", true}, + // Generic crypto + {"server.pem", true}, + {"certs/private.key", true}, + {"path/to/whatever.pem", true}, + // Benign + {"index.html", false}, + {"dist/main.js", false}, + {"assets/logo.svg", false}, + {"README.md", false}, + {"package.json", false}, + } + for _, c := range cases { + if got := isSensitiveRelPath(c.rel); got != c.want { + t.Errorf("isSensitiveRelPath(%q) = %v, want %v", c.rel, got, c.want) + } + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go new file mode 100644 index 00000000..795fc0ec --- /dev/null +++ b/shortcuts/apps/shortcuts.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all apps domain shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + AppsCreate, + AppsUpdate, + AppsList, + AppsAccessScopeSet, + AppsAccessScopeGet, + AppsHTMLPublish, + } +} diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go new file mode 100644 index 00000000..04fc8a04 --- /dev/null +++ b/shortcuts/apps/shortcuts_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import "testing" + +// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 +func TestAppsShortcuts_Returns6(t *testing.T) { + got := Shortcuts() + if len(got) != 6 { + t.Fatalf("Shortcuts() returned %d entries, want 6", len(got)) + } +} diff --git a/shortcuts/apps/walk_html_publish_candidates.go b/shortcuts/apps/walk_html_publish_candidates.go new file mode 100644 index 00000000..a9ef7941 --- /dev/null +++ b/shortcuts/apps/walk_html_publish_candidates.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/larksuite/cli/extension/fileio" +) + +type htmlPublishCandidate struct { + RelPath string + AbsPath string + Size int64 +} + +// isUnsafeRelPath reports whether a forward-slash relative path contains +// anything that should never be written into a tar header or treated as +// inside-root: leading slash (absolute), .. as a path component (start / +// middle / end / whole), or an embedded null byte. Component-aware so it +// does not false-positive on legitimate filenames that contain ".." as a +// substring (e.g. "archive.tar..bak"). +func isUnsafeRelPath(rel string) bool { + return strings.HasPrefix(rel, "/") || + rel == ".." || + strings.HasPrefix(rel, "../") || + strings.Contains(rel, "/../") || + strings.HasSuffix(rel, "/..") || + strings.ContainsRune(rel, 0) +} + +// walkHTMLPublishCandidates walks rootPath and returns each regular file as a +// candidate. Stat goes through fileio so SafeInputPath validation runs on the +// root; the directory walk itself uses filepath.WalkDir because runtime.FileIO +// has no WalkDir equivalent today. +func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublishCandidate, error) { + stat, err := fio.Stat(rootPath) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", rootPath, err) + } + if !stat.IsDir() { + return []htmlPublishCandidate{{ + RelPath: filepath.Base(rootPath), + AbsPath: rootPath, + Size: stat.Size(), + }}, nil + } + + var out []htmlPublishCandidate + //nolint:forbidigo // fileio has no WalkDir; rootPath is already validated above via fio.Stat -> SafeInputPath. + err = filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + // 只接受 regular file —— symlink / device / pipe / socket 都跳过。 + // symlink 不跟随是设计决策(避免 loop + out-of-root 引用),且 fio.Open 也会拒非 regular。 + if !info.Mode().IsRegular() { + return nil + } + rel, err := filepath.Rel(rootPath, path) + if err != nil { + return err + } + relSlash := filepath.ToSlash(rel) + // Defense in depth: WalkDir + Rel inside rootPath should never yield a + // path with .. components, but a future logic change or unusual + // filesystem layout shouldn't be able to inject one into RelPath. + // Mirrors the same guard at tar entry write time. + if isUnsafeRelPath(relSlash) { + return fmt.Errorf("walker produced unsafe relative path %q for %s", relSlash, path) + } + out = append(out, htmlPublishCandidate{ + RelPath: relSlash, + AbsPath: path, + Size: info.Size(), + }) + return nil + }) + return out, err +} diff --git a/shortcuts/apps/walk_html_publish_candidates_test.go b/shortcuts/apps/walk_html_publish_candidates_test.go new file mode 100644 index 00000000..7f881cbc --- /dev/null +++ b/shortcuts/apps/walk_html_publish_candidates_test.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "io" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/larksuite/cli/extension/fileio" +) + +// permissiveFIO is a test-only fileio that delegates to os without +// SafeInputPath validation. Unit tests use it so we can drive the walker +// and tarball algorithms with absolute t.TempDir paths; production code +// goes through LocalFileIO which is cwd-bounded. +type permissiveFIO struct{} + +func (permissiveFIO) Open(name string) (fileio.File, error) { return os.Open(name) } +func (permissiveFIO) Stat(name string) (fileio.FileInfo, error) { return os.Stat(name) } +func (permissiveFIO) ResolvePath(p string) (string, error) { return p, nil } +func (permissiveFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { + panic("Save not used in apps unit tests") +} + +func newTestFIO() fileio.FileIO { return permissiveFIO{} } + +func TestWalkHTMLPublishCandidates_SingleFile(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "index.html") + if err := os.WriteFile(file, []byte(""), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + got, err := walkHTMLPublishCandidates(newTestFIO(), file) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(got) != 1 || got[0].RelPath != "index.html" || got[0].Size != 13 { + t.Fatalf("got=%+v", got) + } +} + +func TestWalkHTMLPublishCandidates_Directory(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "index.html": "", + "css/main.css": "body{}", + "assets/logo.svg": "", + } + for rel, content := range files { + full := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + } + + got, err := walkHTMLPublishCandidates(newTestFIO(), dir) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(got) != 3 { + t.Fatalf("got %d candidates, want 3", len(got)) + } + rels := make([]string, 3) + for i, c := range got { + rels[i] = c.RelPath + } + sort.Strings(rels) + want := []string{"assets/logo.svg", "css/main.css", "index.html"} + for i, w := range want { + if rels[i] != w { + t.Fatalf("rel[%d]=%q want %q", i, rels[i], w) + } + } +} + +func TestWalkHTMLPublishCandidates_NotFound(t *testing.T) { + if _, err := walkHTMLPublishCandidates(newTestFIO(), "/nonexistent/xyz"); err == nil { + t.Fatalf("expected error") + } +} + +func TestIsUnsafeRelPath(t *testing.T) { + cases := []struct { + rel string + want bool + }{ + {"index.html", false}, + {"assets/logo.svg", false}, + {"deep/nested/path/file.html", false}, + {"archive.tar..bak", false}, + {"version.1..2.html", false}, + {"..config", false}, + {"", false}, + {"/etc/passwd", true}, + {"..", true}, + {"../etc/passwd", true}, + {"a/../../etc/passwd", true}, + {"a/..", true}, + {"evil\x00.html", true}, + } + for _, c := range cases { + if got := isUnsafeRelPath(c.rel); got != c.want { + t.Errorf("isUnsafeRelPath(%q) = %v, want %v", c.rel, got, c.want) + } + } +} + +func TestWalkHTMLPublishCandidates_SymlinkSkipped(t *testing.T) { + // Walker 只接受 regular file —— symlink 跳过(避免 loop + out-of-root 引用, + // 且 fio.Open 对 symlink 行为不一致)。real.html 仍然被收,link.html 不在结果里。 + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "real.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.Symlink(filepath.Join(dir, "real.html"), filepath.Join(dir, "link.html")); err != nil { + t.Skipf("symlink not supported on this filesystem: %v", err) + } + got, err := walkHTMLPublishCandidates(newTestFIO(), dir) + if err != nil { + t.Fatalf("err=%v", err) + } + rels := make(map[string]bool) + for _, c := range got { + rels[c.RelPath] = true + } + if !rels["real.html"] { + t.Fatalf("expected real.html (regular file) in candidates, got %+v", got) + } + if rels["link.html"] { + t.Fatalf("symlink link.html should NOT appear in candidates, got %+v", got) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 43987078..3a0350c1 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -12,6 +12,7 @@ import ( "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts/apps" "github.com/larksuite/cli/shortcuts/base" "github.com/larksuite/cli/shortcuts/calendar" "github.com/larksuite/cli/shortcuts/common" @@ -35,6 +36,7 @@ import ( var allShortcuts []common.Shortcut func init() { + allShortcuts = append(allShortcuts, apps.Shortcuts()...) allShortcuts = append(allShortcuts, calendar.Shortcuts()...) allShortcuts = append(allShortcuts, doc.Shortcuts()...) allShortcuts = append(allShortcuts, drive.Shortcuts()...) diff --git a/skill-template/domains/apps.md b/skill-template/domains/apps.md new file mode 100644 index 00000000..243cb7cd --- /dev/null +++ b/skill-template/domains/apps.md @@ -0,0 +1,6 @@ +## 妙搭应用(apps)域介绍 + +妙搭是飞书的低代码 / 无代码应用平台。本域命令围绕"妙搭应用"展开: + +- **App(应用)**:用户创建的妙搭应用对象,含 `app_id`、`name`、`description`、`icon_url`;通过 `+html-publish` 发布 HTML 内容 +- **Access Scope(可用范围)**:`specific`(指定可见)/ `public`(互联网公开)/ `tenant`(企业全员)三选一 diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md new file mode 100644 index 00000000..f0cfc938 --- /dev/null +++ b/skills/lark-apps/SKILL.md @@ -0,0 +1,88 @@ +--- +name: lark-apps +description: "飞书妙搭应用(lark-cli apps):把本地 HTML 文件或目录部署为可访问、可分享的妙搭应用(静态网站 / Web 页面),返回访问 URL;并提供应用创建、更新、列出、设置可用范围(specific 指定可见 / public 互联网公开 / tenant 企业全员)等管理能力。当用户说『用 HTML / 网页开发 PPT / 幻灯片 / 演示文稿 / 可演示的 demo』、『部署 / 发布 HTML / 静态网站 / 网页 / dist 目录』、『把 /xxx 中的 HTML 文件用 lark-cli 部署 / 发到妙搭』、『开发一个 xxx 并部署成可以分享的网站 / 可访问的链接 / 可分享 URL』、『生成一个可以发给别人看的 PPT / 页面 / demo』,或提到 妙搭 / miaoda / apps / app_id / 可用范围 / open-to-tenant / open-to-public 等关键词时使用。**部署策略:用户明示『部署 / 发布 / 分享 / 可访问 / 可分享 URL』时直接走 `apps +html-publish` 自动部署并返回 URL;用户只说『可演示 / 写一个 PPT / 做个 demo』等模糊意图时,HTML 写完后先询问『要部署到妙搭以便分享吗?』再决定。**" +metadata: + requires: + bins: ["lark-cli"] + cliHelp: "lark-cli apps --help; lark-cli apps +create --help; lark-cli apps +html-publish --help; lark-cli apps +access-scope-set --help; lark-cli apps +update --help" +--- + +# apps (v1) + +```bash +# 常用示例 +lark-cli apps +create --name "客户调研问卷" --app-type HTML +lark-cli apps +html-publish --app-id app_xxx --path ./dist +lark-cli apps +access-scope-set --app-id app_xxx --scope tenant +``` + +## 前置条件 — 执行操作前必读 + +**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:** +1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用) +2. **创建应用(`apps +create`)** → 必读 [`lark-apps-create.md`](references/lark-apps-create.md) +3. **更新应用元信息(`apps +update`)** → 必读 [`lark-apps-update.md`](references/lark-apps-update.md)(部分更新,未传字段不变) +4. **发布 HTML / PPT / 静态网站(`apps +html-publish`)** → 必读 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)(`--path` 文件 vs 目录、tar.gz 打包不做过滤) +5. **设置可用范围(`apps +access-scope-set`)** → 必读 [`lark-apps-access-scope-set.md`](references/lark-apps-access-scope-set.md)(specific / public / tenant 三态互斥校验、targets JSON 结构) + +**未读完以上文件就执行相应操作会导致参数选择错误、互斥违反或文件被错误打包。** + +## 身份与一次性授权 + +妙搭应用是用户的个人资产,**统一使用 `--as user`**(CLI 默认 `--as auto` 会按 shortcut 声明自动落到 user)。 + +**首次操作前一次性把本域 scope 全拿到,避免每条命令首次跑都触发新一轮授权**: + +```bash +lark-cli auth login --domain apps +``` + +## 端到端流程(HTML / PPT / 静态网站发布) + +**第一步:判断用户意图是「明示部署」还是「仅演示」**: + +| 用户表达 | 意图 | 处理 | +|---------|------|------| +| "部署 ./xxx 的 HTML"、"发布到妙搭"、"开发 xxx 并部署成可分享的网站 / 可访问的链接"、"生成可分享 URL" | **明示部署 / 分享** | 不停下追问,HTML 写完直接走下表 step 1→2 | +| "用 HTML 写一个 PPT / 幻灯片 / 演示文稿"、"做个可演示的 demo"、"写个介绍 xxx 的页面"(没提部署 / 分享 / URL) | **仅演示** | HTML 写完先输出本地文件路径 + 简要说明,**主动追问一句**:"要部署到妙搭以便分享给别人吗?"用户同意再走 step 1→2;用户说不用就停 | + +**第二步:用户同意部署 / 已明示部署后,按下表走完整链路并把最终 URL 返回给用户**: + +| 步骤 | 命令 | 说明 | +|------|------|------| +| 1. 新建应用 | `apps +create --name "<根据内容主题起的应用名>" --app-type HTML` → 从响应里拿 `app_id` | 默认都走新建(**不要尝试搜索 / 枚举已有应用**)。用户明确要复用现有应用时让他提供 **妙搭应用链接** 或 **app_id 字符串**(详见下方"快速决策");`--app-type` 必填,当前只支持 `HTML`(区分大小写),未来扩展 | +| 1.5 预检 | `apps +html-publish --app-id --path --dry-run` 看 `warnings` 字段 | 命中 `.git` / `.env*` / `*.pem` / `*.key` 等敏感文件时**停下来**,把 warnings 列给用户看,确认要继续才走 step 2;用户没确认前不要去掉 `--dry-run` 真发 | +| 2. 发布 HTML | `apps +html-publish --app-id --path <文件或目录>` | 必走 | +| 3. 设置可用范围(可选) | `apps +access-scope-set --app-id --scope tenant\|public\|specific ...` | 用户说"公开 / 全员可见 / 让 Alice 看 / 互联网可分享"等 | + +报告给用户的话术: + +> 应用「{name}」已发布,访问链接:`{url}` + +若用户没指定可用范围且场景明显需要分享,主动追问一句"要设为企业全员 / 互联网公开吗?",但不要为了问而问。 + +## 快速决策 + +- 用户**明示**"部署 / 发布 ./xxx 的 HTML"、"开发 xxx 并部署成可分享的网站 / 可访问的链接"、"发到妙搭" → 直接走「端到端流程」step 1→2,`apps +html-publish` 自动部署并返回 URL,不要追问 +- 用户**只说**"用 HTML 写 PPT / 幻灯片 / 演示文稿 / demo"、"开发一个可演示的页面"(**没提**部署 / 分享 / URL) → HTML 写完先输出本地路径 + 简要说明,主动问一句"要部署到妙搭以便分享吗?",用户同意才走 publish;不要擅自部署,但也不要忘了问 +- 用户说"把应用 X 开放给全员 / 全公司" → `--scope tenant`,不要再传别的 flag +- 用户说"公开 / 让任何人都能访问 / 互联网可见" → `--scope public --require-login=`,二选一 +- 用户说"只让 Alice / 某部门 / 某群访问" → `--scope specific --targets `;姓名先用 `contact +search-user` 换 `ou_id`,群名先用 `im +chat-search` 换 `chat_id` +- 用户没给 app_id → **默认 `apps +create --name "<根据内容主题起的名字>" --app-type HTML` 新建一个**。**不要尝试搜索 / 枚举已有应用** —— 列举应用的命令对 Agent 不可见,强行调用也只会浪费一次 OAPI 请求。如果用户明确要复用现有应用,**让他提供下列任一种**: + - **妙搭应用链接**:形如 `https://miaoda.feishu.cn/app/app_xxxxxxxxxxxxx`(或带尾斜杠 `/app/app_xxx/`)—— `app_id` 是 `/app/` 后面的 path segment(以 `app_` 开头)。从 URL 中提取的简单办法:`APP_ID=$(echo "$URL" | sed -E 's|.*/app/([^/?#]+).*|\1|')` + - **app_id 字符串**:用户直接给的 `app_xxxxxxxxxxxxx`,不需要再做处理 +- `--path` 既可传单个 HTML 文件也可传目录;目录会**递归打包成 tar.gz 不做过滤**,要提醒用户传干净的产物目录(如 `./dist`),避免把 `.git` / `node_modules` 一起打进去 +- `apps +update` 只更新传入字段,未传字段保持不变;`--name` / `--description` 至少传一个,否则 Validate 阶段直接拦截 +- `apps +access-scope-set` 三种 scope **互斥**:specific 必传 `--targets`、不允许 `--require-login`;public 必传 `--require-login`、不允许 `--targets` / `--apply-enabled` / `--approver`;tenant 不允许任何其他 flag +- 失败时**优先转述 `error.hint`**(CLI 给的可执行修复建议),hint 为空时退回 `error.message`;不要原样把 envelope JSON 复述给用户 + +## Shortcuts(推荐优先使用) + +Shortcut 是对常用操作的高级封装(`lark-cli apps + [flags]`)。有 Shortcut 的操作优先使用。 + +| Shortcut | 说明 | +|----------|------| +| [`+create`](references/lark-apps-create.md) | 创建妙搭应用(name / description / icon-url) | +| [`+update`](references/lark-apps-update.md) | 部分更新应用名 / 描述(只发传入字段) | +| [`+access-scope-set`](references/lark-apps-access-scope-set.md) | 设置应用可用范围(specific / public / tenant,三态互斥校验) | +| [`+html-publish`](references/lark-apps-html-publish.md) | **把本地 HTML 文件 / 目录 / PPT / 静态网站部署为可分享的妙搭应用,返回访问 URL**(用户明示部署 / 分享时直接调;仅说"可演示"时先问用户是否要部署再调) | diff --git a/skills/lark-apps/references/lark-apps-access-scope-get.md b/skills/lark-apps/references/lark-apps-access-scope-get.md new file mode 100644 index 00000000..0a21a09c --- /dev/null +++ b/skills/lark-apps/references/lark-apps-access-scope-get.md @@ -0,0 +1,104 @@ +# apps +access-scope-get + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 + +获取应用当前的可用范围配置。一次 `GET /apps/{appId}/access-scope` 调用,响应原样透传服务端契约(字符串 scope 枚举 + 拆分数组)。 + +## 命令 + +```bash +lark-cli apps +access-scope-get --app-id app_xxx +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|---|---|---| +| `--app-id ` | ✅ | 应用 ID | + +## 返回值 + +**成功(specific,三种 target 类型混合):** + +```json +{ + "ok": true, + "data": { + "scope": "Range", + "users": ["ou_xxx", "ou_yyy"], + "departments": ["od_xxx"], + "chats": ["oc_xxx"], + "apply_config": { + "enabled": true, + "approvers": ["ou_approver"] + } + } +} +``` + +**成功(public + 免登):** + +```json +{ "ok": true, "data": { "scope": "All", "require_login": false } } +``` + +**成功(tenant):** + +```json +{ "ok": true, "data": { "scope": "Tenant" } } +``` + +**失败:** + +```json +{ "ok": false, "error": { "type": "api_error", "message": "...", "hint": "..." } } +``` + +## 字段语义 + +- `scope` 是**字符串枚举**: + - `"All"` = 互联网公开 — 对应 `apps +access-scope-set --scope public` + - `"Tenant"` = 组织内 — 对应 `--scope tenant` + - `"Range"` = 部分人员 — 对应 `--scope specific` +- `users` / `departments` / `chats` 三个数组(仅 `scope="Range"` 时):服务端拆分形态,CLI 不合并回统一 targets +- `apply_config`(可选,仅 `scope="Range"` 且申请开启时):含 `enabled` 和 `approvers`(只允许一个 user open_id) +- `require_login`(仅 `scope="All"` 时):bool + +## 典型场景 + +### 场景 1:查看当前应用对谁可见 + +```bash +lark-cli apps +access-scope-get --app-id app_xxx +``` + +按 `scope` 值组装报告: +- `scope="All"` → "应用 `{app_id}` 当前互联网公开(require_login={require_login})" +- `scope="Tenant"` → "应用 `{app_id}` 当前对企业全员可见" +- `scope="Range"` → "应用 `{app_id}` 当前指定可见,包含 N 个用户 / M 个部门 / K 个群" + +### 场景 2:把 GET 响应拼回 `+access-scope-set` 命令(复制 / 备份可用范围) + +```bash +# 拼一个 --targets JSON 数组(jq) +lark-cli apps +access-scope-get --app-id app_src -q ' + .data + | (.users // [] | map({type:"user", id:.})) + + (.departments // [] | map({type:"department", id:.})) + + (.chats // [] | map({type:"chat", id:.})) +' +``` + +得到 `[{"type":"user","id":"ou_x"}, ...]` 数组,可作为 `apps +access-scope-set --targets '...'` 的入参。 + +## 协同命令 + +| 场景 | 命令 | +|---|---| +| 设置可用范围 | `apps +access-scope-set` | +| 拿 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) | + +## 参考 + +- [lark-apps](../SKILL.md) +- [lark-shared](../../lark-shared/SKILL.md) diff --git a/skills/lark-apps/references/lark-apps-access-scope-set.md b/skills/lark-apps/references/lark-apps-access-scope-set.md new file mode 100644 index 00000000..41552f39 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-access-scope-set.md @@ -0,0 +1,126 @@ +# apps +access-scope-set + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 + +设置应用的可用范围。三种 scope 形态互斥:`specific`(指定可见)、`public`(互联网公开)、`tenant`(企业全员)。 + +## 命令 + +```bash +# 指定可见 + 允许申请(targets 支持 user / department / chat 三种类型) +lark-cli apps +access-scope-set --app-id app_xxx \ + --scope specific \ + --targets '[{"type":"user","id":"ou_xxx"},{"type":"department","id":"od_xxx"},{"type":"chat","id":"oc_xxx"}]' \ + --apply-enabled \ + --approver ou_yyy + +# 互联网公开 + 免登 +lark-cli apps +access-scope-set --app-id app_xxx --scope public --require-login=false + +# 企业全员 +lark-cli apps +access-scope-set --app-id app_xxx --scope tenant +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|---|---|---| +| `--app-id ` | ✅ | 应用 ID | +| `--scope ` | ✅ | `specific` / `public` / `tenant` | +| `--targets ` | scope=specific 必填 | targets JSON 数组,每项 `{"type":"user\|department\|chat", "id":""}` | +| `--apply-enabled` | scope=specific 可选 | 是否允许申请访问 | +| `--approver ` | `--apply-enabled` 必填 | 申请审批人(**只能传一个 user open_id**,服务端限制) | +| `--require-login` | scope=public 必填 | 是否要求登录 | + +## 互斥校验(Validate 阶段,不通过直接报错不发请求) + +- `scope=specific`:必传 `--targets`;不允许 `--require-login` +- `scope=public`:必传 `--require-login`;不允许 `--targets` / `--apply-enabled` / `--approver` +- `scope=tenant`:不允许任何其它 flag +- `--targets` 内每项的 `type` 必须是 `user` / `department` / `chat` 之一 + +## 返回值 + +**成功:** + +```json +{ "ok": true, "data": {} } +``` + +**API 失败:** + +```json +{ "ok": false, "error": { "type": "api_error", "message": "...", "hint": "..." } } +``` + +**Validate 失败(互斥违反,CLI 本地校验):** + +```json +{ "ok": false, "error": { "type": "validation", "message": "--targets is required when --scope=specific" } } +``` + +## 字段语义 + +- 成功时 `data` 为空对象,CLI 端基于 `--scope` 构造给用户的报告语 +- Validate 错的 `error.type=validation` 是本地校验,**不发请求** + +## 典型场景 + +### 场景 1:用户说"把应用 X 开放给全员" + +```bash +lark-cli apps +access-scope-set --app-id app_xxx --scope tenant +``` + +> 应用 `{app_id}` 可用范围已设为企业全员。 + +### 场景 2:用户说"把应用 X 设为互联网公开 + 免登" + +```bash +lark-cli apps +access-scope-set --app-id app_xxx --scope public --require-login=false +``` + +> 应用 `{app_id}` 可用范围已设为互联网公开(免登)。 + +### 场景 3:用户说"只让 Alice 和 Bob 访问应用 X" + +先用 `lark-cli contact +search-user --query Alice` 拿到 ou_id,再调: + +```bash +lark-cli apps +access-scope-set --app-id app_xxx \ + --scope specific \ + --targets '[{"type":"user","id":"ou_alice"},{"type":"user","id":"ou_bob"}]' +``` + +> 应用 `{app_id}` 可用范围已设为指定可见,目标人数 2。 + +### 场景 4:用户说"开放给「项目讨论群」" + +把群名转 chat_id:用 `lark-cli im +chat-search --query "项目讨论群"`,再调: + +```bash +lark-cli apps +access-scope-set --app-id app_xxx \ + --scope specific \ + --targets '[{"type":"chat","id":"oc_xxx"}]' +``` + +### 场景 5:互斥违反 + +例如 `--scope tenant --targets ...` —— Validate 本地拦截。**不发请求**。 + +### 场景 6:API 失败 + +转述 `error.hint` / `error.message`。 + +## 协同命令 + +| 场景 | 命令 | +|---|---| +| 拿 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) | +| 把人名转 ou_id | `lark-cli contact +search-user --query ` | +| 把群名转 chat_id | `lark-cli im +chat-search --query <群名>` | + +## 参考 + +- [lark-apps](../SKILL.md) +- [lark-shared](../../lark-shared/SKILL.md) diff --git a/skills/lark-apps/references/lark-apps-create.md b/skills/lark-apps/references/lark-apps-create.md new file mode 100644 index 00000000..da6a8bc2 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-create.md @@ -0,0 +1,112 @@ +# apps +create + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +创建一个新的妙搭应用。一次 `POST /apps` 调用,返回新建应用的元信息。 + +## 命令 + +```bash +# 最小调用 +lark-cli apps +create --name "客户调研问卷" --app-type HTML + +# 全参数 +lark-cli apps +create \ + --name "客户调研问卷" \ + --app-type HTML \ + --description "本季度客户满意度调研" \ + --icon-url "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg" + +# Dry-run(仅打印请求,不执行) +lark-cli apps +create --name "Demo" --app-type HTML --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|---|---|---| +| `--name ` | ✅ | 应用显示名 | +| `--app-type ` | ✅ | 应用类型,当前可选值:`HTML`(区分大小写;未来会扩展) | +| `--description ` | ❌ | 应用描述 | +| `--icon-url ` | ❌ | 应用图标 URL;不传服务端给默认图标 | + +## 返回值 + +**成功:** + +```json +{ + "ok": true, + "data": { + "app_id": "app_4k5jepcbjmv6m", + "name": "客户调研问卷", + "description": "本季度客户满意度调研", + "icon_url": "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg", + "created_at": "2026-05-18T10:00:00Z" + } +} +``` + +**失败:** + +```json +{ + "ok": false, + "error": { + "type": "api_error", + "code": "api_error", + "message": "...", + "hint": "可执行的修复建议(可能为空)" + } +} +``` + +## 字段语义 + +- `app_type` 是应用类型枚举,**区分大小写**,当前只允许 `HTML`,未来会扩展(如 `SPA`、`NATIVE` 等);不在白名单的取值 CLI 端会直接拒绝 +- `created_at` 是 ISO 8601 UTC 时间字符串 +- `error.hint` 是 CLI 给出的可执行修复建议,**优先**转述给用户;hint 为空时退回 `error.message` +- 不要原样把 envelope JSON 复述给用户 + +## 典型场景 + +### 场景 1:用户说"创建一个妙搭应用,名字叫 X" + +目前只支持 HTML 类型,统一传 `--app-type HTML`(用户没说类型时不要追问,直接用大写 HTML,区分大小写): + +```bash +lark-cli apps +create --name "X" --app-type HTML +``` + +向用户报告: + +> 应用「{name}」已创建(ID: `{app_id}`)。 + +可选建议下一步: + +> 接下来用 `apps +html-publish --app-id {app_id} --path <你的 HTML 目录>` 发布内容。 + +### 场景 2:用户提供完整元信息 + +```bash +lark-cli apps +create --name "Q4 调研" --app-type HTML --description "..." +``` + +返回后同场景 1。 + +### 场景 3:失败处理 + +转述 `error.hint`(优先)或 `error.message`,**不要**原样输出 envelope JSON。 + +## 协同命令 + +| 场景 | 命令 | +|---|---| +| 修改应用名 / 描述 | `apps +update` | +| 发布 HTML | `apps +html-publish` | +| 拿现有应用 ID | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) | + +## 参考 + +- [lark-apps](../SKILL.md) — 妙搭应用全部命令 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-apps/references/lark-apps-html-publish.md b/skills/lark-apps/references/lark-apps-html-publish.md new file mode 100644 index 00000000..296c0074 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-html-publish.md @@ -0,0 +1,151 @@ +# apps +html-publish + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 + +把本地的 HTML 文件或目录部署为可访问的妙搭应用,响应返回应用的访问链接 `url`。 + +## 命令 + +```bash +# 发布整个目录 +lark-cli apps +html-publish --app-id app_xxx --path ./dist/ + +# 发布单个 HTML 文件 +lark-cli apps +html-publish --app-id app_xxx --path ./index.html + +# 预演(打印文件清单 + SHA256 + 目标 endpoint,不发请求) +lark-cli apps +html-publish --app-id app_xxx --path ./dist --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|---|---|---| +| `--app-id ` | ✅ | 应用 ID。从 `apps +create` 响应里拿;或者从用户给的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取(详见 `../SKILL.md` "用户没给 app_id" 一节) | +| `--path ` | ✅ | 本地文件或目录路径;目录会递归打包成 tar.gz。**必须含 `index.html`**:目录形态时根目录下,单文件形态时文件名必须就是 `index.html`(妙搭统一以 `index.html` 作为应用入口) | + +## 返回值 + +**成功:** + +```json +{ + "ok": true, + "data": { + "url": "https://miaoda.feishu.cn/app/app_4k5jepcbjmv6m" + } +} +``` + +**业务失败(如构建失败、应用不存在):** + +```json +{ + "ok": false, + "error": { + "type": "api_error", + "code": "api_error", + "message": "html-publish failed (code=90001): build failed: dependency conflict", + "hint": "构建失败:用 `lark-cli apps +html-publish --path --dry-run` 检查打包文件清单" + } +} +``` + +**基础设施失败(网络 / HTTP 5xx):** + +```json +{ + "ok": false, + "error": { "type": "infra_error", "message": "...", "hint": "" } +} +``` + +**Validate 失败(本地校验,如缺 --app-id):** + +```json +{ + "ok": false, + "error": { "type": "validation", "message": "--app-id is required" } +} +``` + +## 字段语义 + +| 字段 / 组合 | 含义 | +|---|---| +| `data.url` 存在且无 `error` | 发布成功,URL 可访问 | +| `error.type=api_error` | 业务失败(构建失败、应用不存在等),按 `hint` 引导用户修复 | +| `error.type=infra_error` | 网络 / 服务端 5xx,告诉用户稍后重试 | +| `error.type=validation` | 本地参数错,提示用户修 flag | +| `error.hint` 非空 | **优先转述给用户**,比 `error.message` 更可操作 | + +## 典型场景 + +### 场景 1:用户说"把这个目录发布到妙搭" + +```bash +lark-cli apps +html-publish --app-id app_xxx --path ./dist +``` + +成功后: + +> 应用发布成功!访问 `{url}` 查看。 + +可选追加: + +> 如需让其他人访问,可以用 `apps +access-scope-set` 设置可用范围。 + +### 场景 2:用户没有 app_id + +```bash +APP=$(lark-cli apps +create --name "..." -q '.data.app_id' | tr -d '"') +lark-cli apps +html-publish --app-id "$APP" --path ./dist +``` + +### 场景 3:构建失败(code=90001) + +转述 hint: + +> 构建失败,建议用 `lark-cli apps +html-publish --app-id --path ./dist --dry-run` 看一下打包文件清单是否完整。 + +### 场景 4:应用不存在(code=90002) + +> hint:"应用不存在或无权访问;请用户确认妙搭应用链接 / app_id 是否正确(从 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面取)" + +转述给用户。 + +### 场景 5:网络 / 服务端失败(infra_error) + +> 服务暂时不可用,建议稍后重试。 + +## 敏感文件警告 + +dry-run 输出会扫描 manifest 里的相对路径,命中以下任一模式时把它们列入 envelope 的 `warnings` 字段(advisory,不阻断 dry-run): + +- `.git/`(任意 SCM 内部文件) +- `.env` 或 `.env.*`(环境变量 / API key) +- `.npmrc` / `.netrc`(HTTP 凭据) +- `.ssh/id_rsa*` / `.ssh/id_ed25519*` / `.ssh/id_ecdsa*` / `.ssh/id_dsa*` +- `.aws/credentials` / `.aws/config` / `.docker/config.json` / `.gcloud/...` / `.kube/...` +- `*.pem` / `*.key`(私钥) + +**Agent 行为契约**:dry-run 看到 `warnings` 非空,**必须停下来向用户报告并询问是否继续**;用户确认后才能调真实的 `apps +html-publish`(去掉 `--dry-run`)。 + +## 提示 + +- `--path` **不能等于 cwd**(`.` 或 cwd 等价写法均拒)。原因:递归打包 + 互联网公开的组合下,cwd 根的项目级文件(`.git/` / `.env` / `node_modules` / `.aws/credentials`)会被一并打包并通过 share URL 公开访问。强制指定具体子目录或文件,如 `./dist` / `./public/` / `./index.html` +- `--path` **必须**是 cwd 内的相对路径(如 `./dist`、`./index.html`);绝对路径或越界路径(`../`、`/Users/...`)CLI 会直接拒绝。需要发布 cwd 外的目录时,先切到 agent 工作目录再调,**不要**私自 `cd` 绕过 +- 目录打包成 tar.gz 时**不做过滤**(`.git` / `node_modules` 等会一并打包),让用户传干净的产物目录(如 `./dist`) +- **不要**原样把 envelope JSON 转述给用户 + +## 协同命令 + +| 场景 | 命令 | +|---|---| +| 创建新应用 | `apps +create` | +| 设置可用范围 | `apps +access-scope-set` | + +## 参考 + +- [lark-apps](../SKILL.md) +- [lark-shared](../../lark-shared/SKILL.md) diff --git a/skills/lark-apps/references/lark-apps-list.md b/skills/lark-apps/references/lark-apps-list.md new file mode 100644 index 00000000..268f59fc --- /dev/null +++ b/skills/lark-apps/references/lark-apps-list.md @@ -0,0 +1,95 @@ +# apps +list + +> **⚠️ Hidden 命令(`Hidden: true`)—— 不对 Agent 暴露**:本命令从 `--help` / tab completion / SKILL.md 的 Shortcuts 表中隐去,**Agent 不应主动调用**。 +> +> 需要拿现有应用的 `app_id` 时让用户提供 **妙搭应用链接**(如 `https://miaoda.feishu.cn/app/app_xxxxxxxxxxxxx`)然后从 URL 中提取,或者让用户直接给 `app_id` 字符串。详见 [`../SKILL.md`](../SKILL.md) "用户没给 app_id" 一节。 +> +> 本文件保留是因为命令仍然功能可用(手动调用),下面内容仅供人类参考。 + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 + +列出当前用户名下的妙搭应用。**cursor 分页**:默认拉一页(`--page-size 20`),通过 `--page-token` 拉下一页。 + +## 命令 + +```bash +# 拉第一页(默认 page_size=20) +lark-cli apps +list + +# 自定义页大小 +lark-cli apps +list --page-size 50 + +# 翻页(拿上一次响应的 page_token) +lark-cli apps +list --page-token "eyJQaW5PcmRlciI6..." + +# 取 ID 列表(脚本场景) +lark-cli apps +list -q '.data.items[].app_id' + +# 按名字找 app_id +lark-cli apps +list -q '.data.items[] | select(.name=="客户调研问卷") | .app_id' +``` + +## 参数 + +| 参数 | 必填 | 默认 | 说明 | +|---|---|---|---| +| `--page-size ` | ❌ | `20` | 每页条数 | +| `--page-token ` | ❌ | `""` | 翻页 cursor,从上次响应的 `data.page_token` 拿 | + +## 返回值 + +**成功:** + +```json +{ + "ok": true, + "data": { + "items": [ + { + "app_id": "app_4k5jepcbjmv6m", + "name": "客户调研问卷", + "description": "...", + "icon_url": "...", + "created_at": "2026-05-18T10:00:00Z", + "updated_at": "2026-05-18T10:05:00Z" + } + ], + "page_token": "cursor_next_xxx", + "has_more": true + } +} +``` + +**成功(空列表):** + +```json +{ "ok": true, "data": { "items": [], "has_more": false } } +``` + +**失败:** + +```json +{ "ok": false, "error": { "type": "api_error", "message": "...", "hint": "..." } } +``` + +## 字段语义 + +- `data.items` 长度可能为 0(用户没建过应用) +- `data.has_more=true` 表示还有下一页;用 `data.page_token` 作为下次 `--page-token` 传入 +- `data.has_more=false` 且 `data.page_token` 为空 / 缺省表示已经到末尾 + +## 用途 + +本命令保留可供人类操作员手动调用(例如运维 / 调试场景,按 `name` 搜应用 ID)。**Agent 不应主动调用**:默认行为是 `apps +create` 新建;要复用现有应用,**让用户给妙搭应用链接或 app_id**,详见 [`../SKILL.md`](../SKILL.md) "用户没给 app_id" 一节。 + +## 协同命令 + +| 场景 | 命令 | +|---|---| +| 创建新应用 | `apps +create` | +| 修改应用 | `apps +update` | + +## 参考 + +- [lark-apps](../SKILL.md) +- [lark-shared](../../lark-shared/SKILL.md) diff --git a/skills/lark-apps/references/lark-apps-update.md b/skills/lark-apps/references/lark-apps-update.md new file mode 100644 index 00000000..6580dae9 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-update.md @@ -0,0 +1,86 @@ +# apps +update + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 + +部分更新一个妙搭应用的元信息(名字 / 描述)。**只把传入的字段发给服务端,未传字段保持不变**。 + +## 命令 + +```bash +lark-cli apps +update --app-id app_xxx --name "调研问卷 v2" +lark-cli apps +update --app-id app_xxx --description "新描述" +lark-cli apps +update --app-id app_xxx --name "v2" --description "新描述" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|---|---|---| +| `--app-id ` | ✅ | 应用 ID | +| `--name ` | ❌ | 新名字 | +| `--description ` | ❌ | 新描述 | + +`--name` 和 `--description` 至少传一个,否则 Validate 阶段报错。 + +## 返回值 + +**成功:** + +```json +{ + "ok": true, + "data": { + "app_id": "app_4k5jepcbjmv6m", + "name": "调研问卷 v2", + "description": "...", + "icon_url": "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg", + "created_at": "2026-05-18T10:00:00Z", + "updated_at": "2026-05-18T10:05:00Z" + } +} +``` + +**失败:** + +```json +{ + "ok": false, + "error": { "type": "api_error", "message": "...", "hint": "..." } +} +``` + +## 字段语义 + +- 响应 `data` 含完整应用对象(所有字段),不只是被改的 +- `created_at` / `updated_at` 都是 ISO 8601 UTC 时间字符串 +- 失败时优先转述 `error.hint` + +## 典型场景 + +### 场景 1:用户说"把应用 X 改名叫 Y" + +```bash +lark-cli apps +update --app-id app_xxx --name "Y" +``` + +> 应用 `{app_id}` 已更新,新名字「{name}」。 + +### 场景 2:缺 `--app-id` 或没传可更新字段 + +Validate 直接拦截,提示用户加 flag。 + +### 场景 3:失败处理 + +转述 `error.hint` / `error.message`。 + +## 协同命令 + +| 场景 | 命令 | +|---|---| +| 找 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) | +| 创建新应用 | `apps +create` | + +## 参考 + +- [lark-apps](../SKILL.md) +- [lark-shared](../../lark-shared/SKILL.md) diff --git a/tests/cli_e2e/apps/apps_access_scope_get_dryrun_test.go b/tests/cli_e2e/apps/apps_access_scope_get_dryrun_test.go new file mode 100644 index 00000000..9cd03d34 --- /dev/null +++ b/tests/cli_e2e/apps/apps_access_scope_get_dryrun_test.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestAppsAccessScopeGetDryRun pins URL shape and --app-id requirement for the +// read-side companion of +access-scope-set. Response passthrough (scope enum, +// split user/department/chat arrays) is covered by unit tests in shortcuts/apps. +func TestAppsAccessScopeGetDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("HappyPath", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+access-scope-get", + "--app-id", "app_x", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", gjson.Get(result.Stdout, "api.0.url").String()) + // GET request: no body and no query params. + assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists()) + assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists()) + }) + + t.Run("RejectsMissingAppID", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+access-scope-get", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + // cobra Required failures exit with code 1 (distinct from output.ErrValidation + // at code 2). Message goes to stderr as plain text, but we read combined output + // to stay robust to future runner changes. + result.AssertExitCode(t, 1) + assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-id" not set`) + }) +} diff --git a/tests/cli_e2e/apps/apps_access_scope_set_dryrun_test.go b/tests/cli_e2e/apps/apps_access_scope_set_dryrun_test.go new file mode 100644 index 00000000..0b94e1ce --- /dev/null +++ b/tests/cli_e2e/apps/apps_access_scope_set_dryrun_test.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestAppsAccessScopeSetDryRun pins the user-facing scope-string -> server-enum +// mapping (public->All, tenant->Tenant, specific->Range) and the three-way +// mutex between specific / public / tenant. +func TestAppsAccessScopeSetDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("SpecificMapsToRange", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+access-scope-set", + "--app-id", "app_x", + "--scope", "specific", + "--targets", `[{"type":"user","id":"ou_x"},{"type":"chat","id":"oc_x"}]`, + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "PUT", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, "Range", gjson.Get(result.Stdout, "api.0.body.scope").String()) + assert.Equal(t, "ou_x", gjson.Get(result.Stdout, "api.0.body.users.0").String()) + assert.Equal(t, "oc_x", gjson.Get(result.Stdout, "api.0.body.chats.0").String()) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.departments").Exists(), + "empty department list must be omitted") + assert.False(t, gjson.Get(result.Stdout, "api.0.body.apply_config").Exists()) + }) + + t.Run("SpecificWithApplyConfig", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+access-scope-set", + "--app-id", "app_x", + "--scope", "specific", + "--targets", `[{"type":"user","id":"ou_x"}]`, + "--apply-enabled", + "--approver", "ou_y", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.True(t, gjson.Get(result.Stdout, "api.0.body.apply_config.enabled").Bool()) + assert.Equal(t, "ou_y", gjson.Get(result.Stdout, "api.0.body.apply_config.approvers.0").String()) + }) + + t.Run("PublicMapsToAll", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+access-scope-set", + "--app-id", "app_x", + "--scope", "public", + "--require-login=false", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "All", gjson.Get(result.Stdout, "api.0.body.scope").String()) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.require_login").Bool()) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.users").Exists()) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.apply_config").Exists()) + }) + + t.Run("TenantMapsToTenant", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+access-scope-set", + "--app-id", "app_x", + "--scope", "tenant", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "Tenant", gjson.Get(result.Stdout, "api.0.body.scope").String()) + // scope is the only body field in tenant mode. + assert.False(t, gjson.Get(result.Stdout, "api.0.body.require_login").Exists()) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.users").Exists()) + }) + + t.Run("RejectsSpecificMissingTargets", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+access-scope-set", + "--app-id", "app_x", + "--scope", "specific", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), "--targets is required") + }) + + t.Run("RejectsTenantWithExtraFlags", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+access-scope-set", + "--app-id", "app_x", + "--scope", "tenant", + "--targets", `[]`, + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), "no extra flags allowed") + }) + + t.Run("RejectsBadTargetType", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+access-scope-set", + "--app-id", "app_x", + "--scope", "specific", + "--targets", `[{"type":"group","id":"oc_x"}]`, + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), "must be one of") + }) + + t.Run("RejectsApproverWithoutApplyEnabled", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+access-scope-set", + "--app-id", "app_x", + "--scope", "specific", + "--targets", `[{"type":"user","id":"ou_x"}]`, + "--approver", "ou_y", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), "--apply-enabled") + }) +} diff --git a/tests/cli_e2e/apps/apps_create_dryrun_test.go b/tests/cli_e2e/apps/apps_create_dryrun_test.go new file mode 100644 index 00000000..0c4228aa --- /dev/null +++ b/tests/cli_e2e/apps/apps_create_dryrun_test.go @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestAppsCreateDryRun pins the request shape and Validate behavior for +// `apps +create`. The shortcut is UAT-only and posts to the registered +// /open-apis/spark/v1 namespace; both are checked here. +func TestAppsCreateDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("HappyPath_HTMLAppType", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+create", + "--name", "Demo", + "--app-type", "HTML", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String()) + assert.Equal(t, "HTML", gjson.Get(result.Stdout, "api.0.body.app_type").String()) + // Optional fields stay omitted when not provided. + assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists()) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.icon_url").Exists()) + }) + + t.Run("AllFields", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+create", + "--name", "Demo", + "--app-type", "HTML", + "--description", "survey app", + "--icon-url", "https://example.com/icon.svg", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String()) + assert.Equal(t, "HTML", gjson.Get(result.Stdout, "api.0.body.app_type").String()) + assert.Equal(t, "survey app", gjson.Get(result.Stdout, "api.0.body.description").String()) + assert.Equal(t, "https://example.com/icon.svg", gjson.Get(result.Stdout, "api.0.body.icon_url").String()) + }) + + t.Run("RejectsMissingName", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+create", + "--app-type", "HTML", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + // cobra Required failures exit with code 1 (distinct from output.ErrValidation + // at code 2). Message goes to stderr as plain text, but we read combined output + // to stay robust to future runner changes. + result.AssertExitCode(t, 1) + assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "name" not set`) + }) + + t.Run("RejectsBlankName", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+create", + "--name", " ", + "--app-type", "HTML", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + msg := validateErrorMessage(result) + assert.Contains(t, msg, "--name is required") + }) + + t.Run("RejectsMissingAppType", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+create", + "--name", "Demo", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 1) + assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-type" not set`) + }) + + t.Run("RejectsInvalidAppType", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+create", + "--name", "Demo", + "--app-type", "spa", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + msg := validateErrorMessage(result) + assert.Contains(t, msg, "not supported") + assert.Contains(t, msg, "HTML") + }) + + t.Run("RejectsLowercaseAppType", func(t *testing.T) { + // app-type is case-sensitive; lowercase "html" must be rejected even though + // it differs from the allowed "HTML" by case alone. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+create", + "--name", "Demo", + "--app-type", "html", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + msg := validateErrorMessage(result) + assert.True(t, strings.Contains(msg, `"html"`) && strings.Contains(msg, "not supported"), + "expected case-sensitive rejection, got: %s", msg) + }) +} diff --git a/tests/cli_e2e/apps/apps_html_publish_dryrun_test.go b/tests/cli_e2e/apps/apps_html_publish_dryrun_test.go new file mode 100644 index 00000000..265400e8 --- /dev/null +++ b/tests/cli_e2e/apps/apps_html_publish_dryrun_test.go @@ -0,0 +1,290 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestAppsHTMLPublishDryRun exercises the walker / manifest layer without +// packing or uploading. --path goes through LocalFileIO which bounds reads to +// the runtime cwd, so each sub-test seeds fixtures in a t.TempDir and runs +// the binary with WorkDir set to that dir + relative --path. +// +// Hidden files are intentionally included — the walker is deliberately not +// filtering, so the manifest must reflect everything the user pointed --path +// at. Users are documented to pass clean build output directories (e.g. +// ./dist), not source trees. +func TestAppsHTMLPublishDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("Directory_ReportsManifest", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("hi"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "logo.svg"), []byte(""), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+html-publish", + "--app-id", "app_x", + "--path", "./dist", + "--dry-run", + }, + DefaultAs: "user", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code", gjson.Get(result.Stdout, "api.0.url").String()) + // file_count / files / total_size_bytes sit at envelope top level + // (not under api.0.body — manifest is dry-run metadata, not the HTTP body). + assert.Equal(t, int64(2), gjson.Get(result.Stdout, "file_count").Int()) + assert.Greater(t, gjson.Get(result.Stdout, "total_size_bytes").Int(), int64(0)) + files := gjson.Get(result.Stdout, "files").Array() + require.Len(t, files, 2) + names := []string{files[0].String(), files[1].String()} + assert.Contains(t, names, "index.html") + assert.Contains(t, names, "logo.svg") + }) + + t.Run("SingleFile_OneEntry", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "page.html"), []byte(""), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+html-publish", + "--app-id", "app_x", + "--path", "page.html", + "--dry-run", + }, + DefaultAs: "user", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int()) + assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String()) + }) + + t.Run("HiddenFilesIncluded", func(t *testing.T) { + // Walker MUST NOT silently filter .git / .DS_Store — that's an explicit + // design decision so users pass clean ./dist trees, not source repos. + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte(""), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".DS_Store"), []byte("noise"), 0o644)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "dist", ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+html-publish", + "--app-id", "app_x", + "--path", "./dist", + "--dry-run", + }, + DefaultAs: "user", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, int64(3), gjson.Get(result.Stdout, "file_count").Int(), + "walker must include hidden files; got: %s", result.Stdout) + }) + + t.Run("EmptyDir_ManifestEmpty", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755)) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+html-publish", + "--app-id", "app_x", + "--path", "./dist", + "--dry-run", + }, + DefaultAs: "user", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, int64(0), gjson.Get(result.Stdout, "file_count").Int()) + assert.Equal(t, int64(0), gjson.Get(result.Stdout, "total_size_bytes").Int()) + assert.Contains(t, gjson.Get(result.Stdout, "validation_error").String(), "index.html", + "empty dir should report index.html validation_error: %s", result.Stdout) + }) + + t.Run("MissingIndexHTML_SurfacesValidationError", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "page.html"), []byte(""), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+html-publish", + "--app-id", "app_x", + "--path", "./dist", + "--dry-run", + }, + DefaultAs: "user", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int()) + assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String()) + assert.Contains(t, gjson.Get(result.Stdout, "validation_error").String(), "index.html") + }) + + t.Run("RejectsMissingAppID", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte(""), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+html-publish", + "--path", "./dist", + "--dry-run", + }, + DefaultAs: "user", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 1) + assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-id" not set`) + }) + + t.Run("RejectsMissingPath", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+html-publish", + "--app-id", "app_x", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 1) + assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "path" not set`) + }) + + t.Run("WarningsForSensitivePaths", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte(""), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".env"), []byte("SECRET=xxx\n"), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+html-publish", + "--app-id", "app_x", + "--path", "./dist", + "--dry-run", + }, + DefaultAs: "user", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + warnings := gjson.Get(result.Stdout, "warnings").Array() + require.NotEmpty(t, warnings, "expected non-empty warnings for .env: %s", result.Stdout) + var found bool + for _, w := range warnings { + if w.String() == ".env" { + found = true + break + } + } + assert.True(t, found, "warnings should list .env, got %v", warnings) + }) + + t.Run("RejectsPathEqualsCWD", func(t *testing.T) { + // Even with valid index.html in cwd, --path "." must be rejected at + // Validate (so dry-run also rejects) to prevent accidental + // whole-project secrets exfiltration. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+html-publish", + "--app-id", "app_x", + "--path", ".", + "--dry-run", + }, + DefaultAs: "user", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), "当前工作目录") + }) + + t.Run("TrimsAppIDAndPath", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte(""), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+html-publish", + "--app-id", " app_x ", + "--path", " ./dist ", + "--dry-run", + }, + DefaultAs: "user", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code", + gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int(), + "path trimming must produce the same manifest as untrimmed input") + }) +} diff --git a/tests/cli_e2e/apps/apps_list_dryrun_test.go b/tests/cli_e2e/apps/apps_list_dryrun_test.go new file mode 100644 index 00000000..b4ed1536 --- /dev/null +++ b/tests/cli_e2e/apps/apps_list_dryrun_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestAppsListDryRun pins cursor-pagination params: default page_size=20 is +// always written; empty --page-token is omitted; negative page_size is passed +// through unchanged (server is the source of truth for range validation). +func TestAppsListDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("DefaultPageSize", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+list", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String()) + assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(), + "empty page_token must be omitted") + }) + + t.Run("CustomPageSize", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+list", "--page-size", "50", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, "50", gjson.Get(result.Stdout, "api.0.params.page_size").String()) + }) + + t.Run("WithPageToken", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+list", "--page-token", "cursor_abc", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, "cursor_abc", gjson.Get(result.Stdout, "api.0.params.page_token").String()) + assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String()) + }) + + t.Run("NegativePageSizePassesThrough", func(t *testing.T) { + // By design CLI does not bound page_size; server validates. Test pins that + // invariant so a well-meaning client-side check doesn't sneak in. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+list", "--page-size", "-1", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, "-1", gjson.Get(result.Stdout, "api.0.params.page_size").String()) + }) +} diff --git a/tests/cli_e2e/apps/apps_update_dryrun_test.go b/tests/cli_e2e/apps/apps_update_dryrun_test.go new file mode 100644 index 00000000..e644dd72 --- /dev/null +++ b/tests/cli_e2e/apps/apps_update_dryrun_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestAppsUpdateDryRun pins partial-update semantics: PATCH with only the +// fields the user supplied; --app-id and at-least-one-field are both required. +func TestAppsUpdateDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("PartialFieldsName", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+update", + "--app-id", "app_x", + "--name", "v2", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "PATCH", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x", gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, "v2", gjson.Get(result.Stdout, "api.0.body.name").String()) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists(), + "description must be omitted when not provided") + }) + + t.Run("WithDescription", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+update", + "--app-id", "app_x", + "--name", "v2", + "--description", "updated", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "v2", gjson.Get(result.Stdout, "api.0.body.name").String()) + assert.Equal(t, "updated", gjson.Get(result.Stdout, "api.0.body.description").String()) + }) + + t.Run("RejectsMissingAppID", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+update", + "--name", "v2", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 1) + assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-id" not set`) + }) + + t.Run("RejectsNoFields", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+update", + "--app-id", "app_x", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + msg := validateErrorMessage(result) + assert.Contains(t, msg, "at least one") + }) +} diff --git a/tests/cli_e2e/apps/coverage.md b/tests/cli_e2e/apps/coverage.md new file mode 100644 index 00000000..be7a2e19 --- /dev/null +++ b/tests/cli_e2e/apps/coverage.md @@ -0,0 +1,28 @@ +# Apps CLI E2E Coverage + +## Metrics +- Denominator: 6 leaf commands (all shortcuts) +- Covered: 6 (dry-run only) +- Coverage: 100% (dry-run); 0% (live) + +## Summary +- `TestAppsCreateDryRun`: happy path with `--app-type HTML`, all-fields shape, three rejection paths (missing name, missing app-type, invalid app-type, case-sensitive lowercase rejection). +- `TestAppsUpdateDryRun`: partial-field PATCH semantics; `--app-id` and at-least-one-field validation. +- `TestAppsListDryRun`: default `page_size=20`; empty `--page-token` omitted; negative size passed through to server (no client-side bound check). +- `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths. +- `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required. +- `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections. + +Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Revisit when the server exposes `DELETE /apps/{appId}`. + +## Command Table + +| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | +| --- | --- | --- | --- | --- | --- | +| ✓ | apps +create | shortcut | apps_create_dryrun_test.go::TestAppsCreateDryRun | `--name`, `--app-type` (required, case-sensitive, `HTML` only), `--description`, `--icon-url` | live blocked: no +delete to clean up | +| ✓ | apps +update | shortcut | apps_update_dryrun_test.go::TestAppsUpdateDryRun | `--app-id`; at least one of `--name`/`--description` | live blocked: no +delete | +| ✓ | apps +list | shortcut | apps_list_dryrun_test.go::TestAppsListDryRun | `--page-size` default 20; `--page-token` cursor | live blocked: needs tenant fixtures | +| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids | +| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state | +| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API | + diff --git a/tests/cli_e2e/apps/helpers_test.go b/tests/cli_e2e/apps/helpers_test.go new file mode 100644 index 00000000..f82f3904 --- /dev/null +++ b/tests/cli_e2e/apps/helpers_test.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "testing" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/tidwall/gjson" +) + +// setAppsDryRunEnv isolates config and supplies stub credentials so dry-run +// short-circuits before identity / scope resolution touches a real keychain. +// Apps shortcuts are UAT-only, so tests pass DefaultAs:"user" to the harness. +func setAppsDryRunEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "apps_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "apps_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} + +// validateErrorMessage extracts the structured error.message from a dry-run +// Validate-stage failure envelope. Repo convention is "stdout first, stderr +// fallback" — markdown / drive_search emit the JSON envelope to stdout (exit +// 0), apps currently emits to stderr (exit 2). Reading both orders shields +// tests from runner-internal routing changes. +func validateErrorMessage(r *clie2e.Result) string { + if msg := gjson.Get(r.Stdout, "error.message").String(); msg != "" { + return msg + } + return gjson.Get(r.Stderr, "error.message").String() +}