From 06a77dcc68ef9222fd06c6783cd24d0b586d43fc Mon Sep 17 00:00:00 2001 From: raistlin042 Date: Fri, 29 May 2026 17:40:35 +0800 Subject: [PATCH 1/3] feat: add fullstack app-type and --message to apps +create (#1) * feat: accept fullstack app-type and require --message for it * feat: inject message into fullstack create request body * refactor: align fullstack message injection with existing body-build style * docs: document fullstack app-type and --message for apps +create * docs: keep scene numbering consistent in lark-apps-create reference * docs: add HTML/fullstack intent routing to lark-apps SKILL.md * docs: cover fullstack in lark-apps skill description and clarify HTML flow step * test: assert fullstack in allow-list error and reject wrong-cased fullstack --- shortcuts/apps/apps_create.go | 19 ++- shortcuts/apps/apps_create_test.go | 115 ++++++++++++++++++ skills/lark-apps/SKILL.md | 18 ++- .../lark-apps/references/lark-apps-create.md | 41 ++++++- 4 files changed, 180 insertions(+), 13 deletions(-) diff --git a/shortcuts/apps/apps_create.go b/shortcuts/apps/apps_create.go index 215b4ccd2..bbd77bb01 100644 --- a/shortcuts/apps/apps_create.go +++ b/shortcuts/apps/apps_create.go @@ -24,9 +24,10 @@ var AppsCreate = common.Shortcut{ 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: "app-type", Desc: "app type (HTML or fullstack)", Required: true}, {Name: "description", Desc: "app description"}, {Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"}, + {Name: "message", Desc: "user message describing the app to build (required when --app-type is fullstack)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("name")) == "" { @@ -37,7 +38,10 @@ var AppsCreate = common.Shortcut{ 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 output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML, fullstack)", appType)) + } + if appType == "fullstack" && strings.TrimSpace(rctx.Str("message")) == "" { + return output.ErrValidation("--message is required when --app-type is fullstack") } return nil }, @@ -59,15 +63,17 @@ var AppsCreate = common.Shortcut{ }, } -// 应用类型枚举。当前只有 HTML,未来会扩展(SPA、NATIVE、...)。 +// 应用类型枚举。大小写敏感精确匹配。 var validAppTypes = map[string]bool{ - "HTML": true, + "HTML": true, + "fullstack": true, } func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} { + appType := strings.TrimSpace(rctx.Str("app-type")) body := map[string]interface{}{ "name": strings.TrimSpace(rctx.Str("name")), - "app_type": strings.TrimSpace(rctx.Str("app-type")), + "app_type": appType, } if desc := strings.TrimSpace(rctx.Str("description")); desc != "" { body["description"] = desc @@ -75,5 +81,8 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} { if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" { body["icon_url"] = icon } + if msg := strings.TrimSpace(rctx.Str("message")); appType == "fullstack" && msg != "" { + body["message"] = msg + } return body } diff --git a/shortcuts/apps/apps_create_test.go b/shortcuts/apps/apps_create_test.go index 2cee3e581..893456ca6 100644 --- a/shortcuts/apps/apps_create_test.go +++ b/shortcuts/apps/apps_create_test.go @@ -167,6 +167,44 @@ func TestAppsCreate_RejectsInvalidAppType(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "not supported") { t.Fatalf("expected unsupported app-type error, got %v", err) } + if !strings.Contains(err.Error(), "fullstack") { + t.Fatalf("expected allow-list error to mention \"fullstack\", got %v", err) + } +} + +func TestAppsCreate_RejectsWrongCaseFullstack(t *testing.T) { + cases := []string{"FULLSTACK", "Fullstack", "FullStack"} + for _, appType := range cases { + t.Run(appType, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", appType, "--message", "m", "--as", "user"}, + factory, stdout) + if err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("expected case-sensitive rejection of %q, got %v", appType, err) + } + }) + } +} + +func TestAppsCreate_FullstackRequiresMessage(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "fullstack", "--as", "user"}, + factory, stdout) + if err == nil || !strings.Contains(err.Error(), "message is required") { + t.Fatalf("expected message-required error, got %v", err) + } +} + +func TestAppsCreate_FullstackBlankMessageRejected(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "fullstack", "--message", " ", "--as", "user"}, + factory, stdout) + if err == nil || !strings.Contains(err.Error(), "message is required") { + t.Fatalf("expected blank message rejected, got %v", err) + } } func TestAppsCreate_DryRun(t *testing.T) { @@ -187,3 +225,80 @@ func TestAppsCreate_DryRun(t *testing.T) { t.Fatalf("dry-run missing app_type: %s", got) } } + +func TestAppsCreate_FullstackSuccess(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": map[string]interface{}{"app_id": "app_fs", "name": "Demo"}, + }, + }, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "fullstack", "--message", "build a CRM", "--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["app_type"] != "fullstack" { + t.Fatalf("body.app_type = %v (want fullstack)", sent["app_type"]) + } + if sent["message"] != "build a CRM" { + t.Fatalf("body.message = %v (want \"build a CRM\")", sent["message"]) + } +} + +func TestAppsCreate_HTMLIgnoresMessage(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": map[string]interface{}{"app_id": "app_x", "name": "Demo"}, + }, + }, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "HTML", "--message", "ignored", "--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 _, present := sent["message"]; present { + t.Fatalf("message should be omitted for HTML app-type: %v", sent) + } +} + +func TestAppsCreate_FullstackDryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "fullstack", "--message", "m", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"app_type": "fullstack"`) { + t.Fatalf("dry-run missing app_type fullstack: %s", got) + } + if !strings.Contains(got, `"message": "m"`) { + t.Fatalf("dry-run missing message: %s", got) + } +} diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 4aaa3a3fd..2e273edc0 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,6 +1,6 @@ --- name: lark-apps -description: "把本地 HTML 文件或目录部署到飞书妙搭(Miaoda),生成一个公网可访问的应用及其链接(URL)。当用户要创建 HTML 或要把 HTML、静态网站或 Web demo 发布成公网可访问的链接 / 可分享链接、设置应用共享范围,或提到妙搭 / Miaoda 时使用。凡产出可独立访问的 HTML 产物都属本 skill 的潜在归宿,是否真要部署由 skill 内部协议判断。不用于:上传普通文件到云空间/云盘/云存储(用 lark-drive)、编辑飞书云文档内容(用 lark-doc)、创建飞书原生幻灯片 / 演示文稿(用 lark-slides)。" +description: "把本地 HTML 文件或目录部署到飞书妙搭(Miaoda),生成一个公网可访问的应用及其链接(URL)。当用户要创建 HTML 或要把 HTML、静态网站或 Web demo 发布成公网可访问的链接 / 可分享链接、创建全栈(fullstack)应用 / 带后端(数据库、登录、API)的妙搭应用、设置应用共享范围,或提到妙搭 / Miaoda 时使用。凡产出可独立访问的 HTML 产物都属本 skill 的潜在归宿,是否真要部署由 skill 内部协议判断。不用于:上传普通文件到云空间/云盘/云存储(用 lark-drive)、编辑飞书云文档内容(用 lark-doc)、创建飞书原生幻灯片 / 演示文稿(用 lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -12,6 +12,7 @@ metadata: ```bash # 常用示例 lark-cli apps +create --name "客户调研问卷" --app-type HTML +lark-cli apps +create --name "团队任务看板" --app-type fullstack --message "带登录和数据库的任务看板" lark-cli apps +html-publish --app-id app_xxx --path ./dist lark-cli apps +access-scope-set --app-id app_xxx --scope tenant ``` @@ -66,7 +67,7 @@ lark-cli auth login --domain apps | 步骤 | 命令 | 说明 | |------|------|------| -| 1. 新建应用 | `apps +create --name "<根据内容主题起的应用名>" --app-type HTML` → 从响应里拿 `app_id` | 默认都走新建(**不要尝试搜索 / 枚举已有应用**)。用户明确要复用现有应用时让他提供 **妙搭应用链接** 或 **app_id 字符串**(详见下方"快速决策");`--app-type` 必填,当前只支持 `HTML`(区分大小写),未来扩展 | +| 1. 新建应用 | `apps +create --name "<根据内容主题起的应用名>" --app-type HTML` → 从响应里拿 `app_id` | 默认都走新建(**不要尝试搜索 / 枚举已有应用**)。用户明确要复用现有应用时让他提供 **妙搭应用链接** 或 **app_id 字符串**(详见下方"快速决策");应用类型按需选择,见下方「快速决策 > HTML vs fullstack 意图分流」 | | 1.5 预检(可选) | `apps +html-publish --app-id --path --dry-run` 看 manifest | 主要用来看 `files` / `total_size_bytes`。**凭据文件已经在 Validate 阶段直接 exit 非 0**(不再是 advisory warning),所以预检通过就说明走真发也通过;预检报 `.env` 等命中时,先清产物或加 `--allow-sensitive` 再 publish | | 2. 发布 HTML | `apps +html-publish --app-id --path <文件或目录>` | 必走 | | 3. 设置可用范围(可选) | `apps +access-scope-set --app-id --scope tenant\|public\|specific ...` | 用户说"公开 / 全员可见 / 让 Alice 看 / 互联网可分享"等 | @@ -79,12 +80,21 @@ lark-cli auth login --domain apps ## 快速决策 +### HTML vs fullstack 意图分流 + +- 用户要**纯静态页面 / HTML / PPT / 幻灯片 / 单页 / 演示 / Web demo**(无后端逻辑)→ `--app-type HTML`,走现有端到端流程(+create → +html-publish) +- 用户要**带后端能力的全栈应用**(数据库 / 登录鉴权 / API / 表单提交存储 / 用户系统 / 增删改查 / 持久化 / 服务端逻辑 / "全栈" / "带后台" / "后台管理")→ `--app-type fullstack --message "<用户原话>"`,到 `+create` 为止(fullstack 后续本地开发链路待 `+git-credential-init` 就绪后补充) +- 意图模糊、无法判断 → 默认 `HTML`(更轻、且为现有成熟流程),必要时追问一句澄清 +- 详细判定规则见 [`references/lark-apps-create.md`](references/lark-apps-create.md) 的「意图识别」小节 + +### 操作决策 + - 用户**明示**"部署 / 发布 ./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 请求。如果用户明确要复用现有应用,**让他提供下列任一种**: +- 用户没给 app_id → **先按「HTML vs fullstack 意图分流」确定类型,再 `apps +create --name "<根据内容主题起的名字>" --app-type <类型>` 新建一个**。**不要尝试搜索 / 枚举已有应用** —— 列举应用的命令对 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` 一起打进去 @@ -98,7 +108,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli apps + [flags]`) | Shortcut | 说明 | |----------|------| -| [`+create`](references/lark-apps-create.md) | 创建妙搭应用(name / description / icon-url) | +| [`+create`](references/lark-apps-create.md) | 创建妙搭应用(HTML / fullstack;fullstack 需 --message) | | [`+update`](references/lark-apps-update.md) | 部分更新应用名 / 描述(只发传入字段) | | [`+access-scope-set`](references/lark-apps-access-scope-set.md) | 设置应用可用范围(specific / public / tenant,三态互斥校验) | | [`+access-scope-get`](references/lark-apps-access-scope-get.md) | 查看应用当前可用范围(响应 scope 枚举 `All` / `Tenant` / `Range`;可作"备份 / 复制 scope 配置"前置读) | diff --git a/skills/lark-apps/references/lark-apps-create.md b/skills/lark-apps/references/lark-apps-create.md index f30d1bca5..ab0c93b95 100644 --- a/skills/lark-apps/references/lark-apps-create.md +++ b/skills/lark-apps/references/lark-apps-create.md @@ -19,6 +19,10 @@ lark-cli apps +create \ # Dry-run(仅打印请求,不执行) lark-cli apps +create --name "Demo" --app-type HTML --dry-run + +# 创建 fullstack(全栈)应用:必带 --message,原样透传用户需求 +lark-cli apps +create --name "团队任务看板" --app-type fullstack \ + --message "做一个团队任务看板,支持登录、任务增删改查、按人筛选" ``` ## 参数 @@ -26,9 +30,10 @@ lark-cli apps +create --name "Demo" --app-type HTML --dry-run | 参数 | 必填 | 说明 | |---|---|---| | `--name ` | ✅ | 应用显示名 | -| `--app-type ` | ✅ | 应用类型,当前可选值:`HTML`(区分大小写;未来会扩展) | +| `--app-type ` | ✅ | 应用类型,可选值:`HTML` 或 `fullstack`(均区分大小写) | | `--description ` | ❌ | 应用描述 | | `--icon-url ` | ❌ | 应用图标 URL;不传服务端给默认图标 | +| `--message ` | fullstack 时 ✅,HTML 时忽略 | 用户描述应用需求的原话原文;`--app-type fullstack` 时必填,原样透传给服务端;`--app-type HTML` 时即使传了也被静默忽略(不进请求体、不报错) | ## 返回值 @@ -65,16 +70,29 @@ lark-cli apps +create --name "Demo" --app-type HTML --dry-run ## 字段语义 -- `app_type` 是应用类型枚举,**区分大小写**,当前只允许 `HTML`,未来会扩展(如 `SPA`、`NATIVE` 等);不在白名单的取值 CLI 端会直接拒绝 +- `app_type` 是应用类型枚举,**区分大小写**,当前支持 `HTML` 和 `fullstack`(两者均大小写敏感精确匹配,不在白名单的取值 CLI 端直接拒绝) +- `message` 仅 `app_type=fullstack` 时生效,原样透传用户描述需求的原话,不改写、不总结;`HTML` 类型时不进请求体 - `created_at` 是 ISO 8601 UTC 时间字符串 - `error.hint` 是 CLI 给出的可执行修复建议,**优先**转述给用户;hint 为空时退回 `error.message` - 不要原样把 envelope JSON 复述给用户 +## 意图识别:HTML vs fullstack + +在调用 `+create` 前,先根据用户描述判断应用类型: + +| 用户信号 | 判定 | `--app-type` | +|---------|------|-------------| +| 纯静态展示:HTML、PPT、幻灯片、单页、静态站点、Web demo(无后端逻辑) | 静态页面 | `HTML` | +| 需要后端能力:数据库、登录鉴权、API、表单提交存储、用户系统、增删改查、持久化、服务端逻辑、"全栈"、"带后台 / 后台管理" | 全栈应用 | `fullstack` | +| 模糊不清、无法判断 | 默认 `HTML`(更轻、且为现有成熟流程),必要时追问一句澄清 | `HTML` | + +fullstack 判定后:`--message` 取用户描述需求的**原话原文**,不改写、不总结。 + ## 典型场景 ### 场景 1:用户说"创建一个妙搭应用,名字叫 X" -目前只支持 HTML 类型,统一传 `--app-type HTML`(用户没说类型时不要追问,直接用大写 HTML,区分大小写): +先按意图识别判断类型。若用户未表达后端需求(纯展示/静态)或意图模糊,默认用 `--app-type HTML`(更轻、且为现有成熟流程): ```bash lark-cli apps +create --name "X" --app-type HTML @@ -96,7 +114,22 @@ lark-cli apps +create --name "Q4 调研" --app-type HTML --description "..." 返回后同场景 1。 -### 场景 3:失败处理 +### 场景 3:创建 fullstack 应用 + +识别到用户需要全栈/带后端能力的应用后(如需要登录、数据库、增删改查、用户系统等),使用 `--app-type fullstack`,并将用户原话原文作为 `--message` 传入: + +```bash +lark-cli apps +create --name "团队任务看板" --app-type fullstack \ + --message "做一个团队任务看板,支持登录、任务增删改查、按人筛选" +``` + +向用户报告: + +> 全栈应用「{name}」已创建(ID: `{app_id}`)。 + +> ⚠️ **注意**:fullstack 应用创建后续的本地开发链路(git 凭据初始化 + git clone)**待 `+git-credential-init` 命令就绪后补充**,当前版本到 `+create` 为止。 + +### 场景 4:失败处理 转述 `error.hint`(优先)或 `error.message`,**不要**原样输出 envelope JSON。 From 88797de5d11307c9c7a2bcb5a0178110c722925a Mon Sep 17 00:00:00 2001 From: raistlin042 Date: Mon, 1 Jun 2026 14:01:54 +0800 Subject: [PATCH 2/3] feat: drop --message from apps +create (#4) * feat: drop --message from apps +create * docs: drop --message and document agent-generated name/description for apps +create --- shortcuts/apps/apps_create.go | 7 --- shortcuts/apps/apps_create_test.go | 61 ++----------------- skills/lark-apps/SKILL.md | 6 +- .../lark-apps/references/lark-apps-create.md | 12 ++-- 4 files changed, 14 insertions(+), 72 deletions(-) diff --git a/shortcuts/apps/apps_create.go b/shortcuts/apps/apps_create.go index bbd77bb01..64df61a62 100644 --- a/shortcuts/apps/apps_create.go +++ b/shortcuts/apps/apps_create.go @@ -27,7 +27,6 @@ var AppsCreate = common.Shortcut{ {Name: "app-type", Desc: "app type (HTML or fullstack)", Required: true}, {Name: "description", Desc: "app description"}, {Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"}, - {Name: "message", Desc: "user message describing the app to build (required when --app-type is fullstack)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("name")) == "" { @@ -40,9 +39,6 @@ var AppsCreate = common.Shortcut{ if !validAppTypes[appType] { return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML, fullstack)", appType)) } - if appType == "fullstack" && strings.TrimSpace(rctx.Str("message")) == "" { - return output.ErrValidation("--message is required when --app-type is fullstack") - } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { @@ -81,8 +77,5 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} { if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" { body["icon_url"] = icon } - if msg := strings.TrimSpace(rctx.Str("message")); appType == "fullstack" && msg != "" { - body["message"] = msg - } return body } diff --git a/shortcuts/apps/apps_create_test.go b/shortcuts/apps/apps_create_test.go index 893456ca6..34a2ef82a 100644 --- a/shortcuts/apps/apps_create_test.go +++ b/shortcuts/apps/apps_create_test.go @@ -178,7 +178,7 @@ func TestAppsCreate_RejectsWrongCaseFullstack(t *testing.T) { t.Run(appType, func(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", appType, "--message", "m", "--as", "user"}, + []string{"+create", "--name", "Demo", "--app-type", appType, "--as", "user"}, factory, stdout) if err == nil || !strings.Contains(err.Error(), "not supported") { t.Fatalf("expected case-sensitive rejection of %q, got %v", appType, err) @@ -187,26 +187,6 @@ func TestAppsCreate_RejectsWrongCaseFullstack(t *testing.T) { } } -func TestAppsCreate_FullstackRequiresMessage(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", "fullstack", "--as", "user"}, - factory, stdout) - if err == nil || !strings.Contains(err.Error(), "message is required") { - t.Fatalf("expected message-required error, got %v", err) - } -} - -func TestAppsCreate_FullstackBlankMessageRejected(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", "fullstack", "--message", " ", "--as", "user"}, - factory, stdout) - if err == nil || !strings.Contains(err.Error(), "message is required") { - t.Fatalf("expected blank message rejected, got %v", err) - } -} - func TestAppsCreate_DryRun(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsCreate, @@ -241,7 +221,7 @@ func TestAppsCreate_FullstackSuccess(t *testing.T) { reg.Register(stub) if err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", "fullstack", "--message", "build a CRM", "--as", "user"}, + []string{"+create", "--name", "Demo", "--app-type", "fullstack", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -253,44 +233,15 @@ func TestAppsCreate_FullstackSuccess(t *testing.T) { if sent["app_type"] != "fullstack" { t.Fatalf("body.app_type = %v (want fullstack)", sent["app_type"]) } - if sent["message"] != "build a CRM" { - t.Fatalf("body.message = %v (want \"build a CRM\")", sent["message"]) - } -} - -func TestAppsCreate_HTMLIgnoresMessage(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": map[string]interface{}{"app_id": "app_x", "name": "Demo"}, - }, - }, - } - reg.Register(stub) - - if err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", "HTML", "--message", "ignored", "--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 _, present := sent["message"]; present { - t.Fatalf("message should be omitted for HTML app-type: %v", sent) + t.Fatalf("message should never be sent: %v", sent) } } func TestAppsCreate_FullstackDryRun(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", "fullstack", "--message", "m", "--dry-run", "--as", "user"}, + []string{"+create", "--name", "Demo", "--app-type", "fullstack", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -298,7 +249,7 @@ func TestAppsCreate_FullstackDryRun(t *testing.T) { if !strings.Contains(got, `"app_type": "fullstack"`) { t.Fatalf("dry-run missing app_type fullstack: %s", got) } - if !strings.Contains(got, `"message": "m"`) { - t.Fatalf("dry-run missing message: %s", got) + if strings.Contains(got, `"message"`) { + t.Fatalf("dry-run should not contain message: %s", got) } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 2e273edc0..fbede78d6 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -12,7 +12,7 @@ metadata: ```bash # 常用示例 lark-cli apps +create --name "客户调研问卷" --app-type HTML -lark-cli apps +create --name "团队任务看板" --app-type fullstack --message "带登录和数据库的任务看板" +lark-cli apps +create --name "团队任务看板" --app-type fullstack --description "带登录和数据库的任务看板" lark-cli apps +html-publish --app-id app_xxx --path ./dist lark-cli apps +access-scope-set --app-id app_xxx --scope tenant ``` @@ -83,7 +83,7 @@ lark-cli auth login --domain apps ### HTML vs fullstack 意图分流 - 用户要**纯静态页面 / HTML / PPT / 幻灯片 / 单页 / 演示 / Web demo**(无后端逻辑)→ `--app-type HTML`,走现有端到端流程(+create → +html-publish) -- 用户要**带后端能力的全栈应用**(数据库 / 登录鉴权 / API / 表单提交存储 / 用户系统 / 增删改查 / 持久化 / 服务端逻辑 / "全栈" / "带后台" / "后台管理")→ `--app-type fullstack --message "<用户原话>"`,到 `+create` 为止(fullstack 后续本地开发链路待 `+git-credential-init` 就绪后补充) +- 用户要**带后端能力的全栈应用**(数据库 / 登录鉴权 / API / 表单提交存储 / 用户系统 / 增删改查 / 持久化 / 服务端逻辑 / "全栈" / "带后台" / "后台管理")→ `--app-type fullstack`(从用户描述生成 `--name` 和 `--description`),到 `+create` 为止(fullstack 后续本地开发链路待 `+git-credential-init` 就绪后补充) - 意图模糊、无法判断 → 默认 `HTML`(更轻、且为现有成熟流程),必要时追问一句澄清 - 详细判定规则见 [`references/lark-apps-create.md`](references/lark-apps-create.md) 的「意图识别」小节 @@ -108,7 +108,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli apps + [flags]`) | Shortcut | 说明 | |----------|------| -| [`+create`](references/lark-apps-create.md) | 创建妙搭应用(HTML / fullstack;fullstack 需 --message) | +| [`+create`](references/lark-apps-create.md) | 创建妙搭应用(HTML / fullstack;从用户输入生成 name/description) | | [`+update`](references/lark-apps-update.md) | 部分更新应用名 / 描述(只发传入字段) | | [`+access-scope-set`](references/lark-apps-access-scope-set.md) | 设置应用可用范围(specific / public / tenant,三态互斥校验) | | [`+access-scope-get`](references/lark-apps-access-scope-get.md) | 查看应用当前可用范围(响应 scope 枚举 `All` / `Tenant` / `Range`;可作"备份 / 复制 scope 配置"前置读) | diff --git a/skills/lark-apps/references/lark-apps-create.md b/skills/lark-apps/references/lark-apps-create.md index ab0c93b95..bb4a959c6 100644 --- a/skills/lark-apps/references/lark-apps-create.md +++ b/skills/lark-apps/references/lark-apps-create.md @@ -20,9 +20,9 @@ lark-cli apps +create \ # Dry-run(仅打印请求,不执行) lark-cli apps +create --name "Demo" --app-type HTML --dry-run -# 创建 fullstack(全栈)应用:必带 --message,原样透传用户需求 +# 创建 fullstack(全栈)应用:从用户描述生成 name/description lark-cli apps +create --name "团队任务看板" --app-type fullstack \ - --message "做一个团队任务看板,支持登录、任务增删改查、按人筛选" + --description "支持登录、任务增删改查、按人筛选的团队任务看板" ``` ## 参数 @@ -33,7 +33,6 @@ lark-cli apps +create --name "团队任务看板" --app-type fullstack \ | `--app-type ` | ✅ | 应用类型,可选值:`HTML` 或 `fullstack`(均区分大小写) | | `--description ` | ❌ | 应用描述 | | `--icon-url ` | ❌ | 应用图标 URL;不传服务端给默认图标 | -| `--message ` | fullstack 时 ✅,HTML 时忽略 | 用户描述应用需求的原话原文;`--app-type fullstack` 时必填,原样透传给服务端;`--app-type HTML` 时即使传了也被静默忽略(不进请求体、不报错) | ## 返回值 @@ -71,7 +70,6 @@ lark-cli apps +create --name "团队任务看板" --app-type fullstack \ ## 字段语义 - `app_type` 是应用类型枚举,**区分大小写**,当前支持 `HTML` 和 `fullstack`(两者均大小写敏感精确匹配,不在白名单的取值 CLI 端直接拒绝) -- `message` 仅 `app_type=fullstack` 时生效,原样透传用户描述需求的原话,不改写、不总结;`HTML` 类型时不进请求体 - `created_at` 是 ISO 8601 UTC 时间字符串 - `error.hint` 是 CLI 给出的可执行修复建议,**优先**转述给用户;hint 为空时退回 `error.message` - 不要原样把 envelope JSON 复述给用户 @@ -86,7 +84,7 @@ lark-cli apps +create --name "团队任务看板" --app-type fullstack \ | 需要后端能力:数据库、登录鉴权、API、表单提交存储、用户系统、增删改查、持久化、服务端逻辑、"全栈"、"带后台 / 后台管理" | 全栈应用 | `fullstack` | | 模糊不清、无法判断 | 默认 `HTML`(更轻、且为现有成熟流程),必要时追问一句澄清 | `HTML` | -fullstack 判定后:`--message` 取用户描述需求的**原话原文**,不改写、不总结。 +判定类型后:从用户的自然语言输入**生成**一个简洁的 `name` 和一句 `description`,通过 `--name` / `--description` 传入(HTML 与 fullstack 都适用),不要求用户显式给出应用名。 ## 典型场景 @@ -116,11 +114,11 @@ lark-cli apps +create --name "Q4 调研" --app-type HTML --description "..." ### 场景 3:创建 fullstack 应用 -识别到用户需要全栈/带后端能力的应用后(如需要登录、数据库、增删改查、用户系统等),使用 `--app-type fullstack`,并将用户原话原文作为 `--message` 传入: +识别到用户需要全栈/带后端能力的应用后(如需要登录、数据库、增删改查、用户系统等),使用 `--app-type fullstack`,并从用户描述生成 `name` 和 `description`: ```bash lark-cli apps +create --name "团队任务看板" --app-type fullstack \ - --message "做一个团队任务看板,支持登录、任务增删改查、按人筛选" + --description "支持登录、任务增删改查、按人筛选的团队任务看板" ``` 向用户报告: From cccd9092f0e517744a2a962ac4db3bdb4a60e4a6 Mon Sep 17 00:00:00 2001 From: raistlin042 Date: Mon, 1 Jun 2026 21:45:37 +0800 Subject: [PATCH 3/3] feat: add apps local key-value file storage (#5) --- shortcuts/apps/storage.go | 129 +++++++++++++++++++ shortcuts/apps/storage_test.go | 219 +++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 shortcuts/apps/storage.go create mode 100644 shortcuts/apps/storage_test.go diff --git a/shortcuts/apps/storage.go b/shortcuts/apps/storage.go new file mode 100644 index 000000000..349ef397e --- /dev/null +++ b/shortcuts/apps/storage.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +// storageRoot is the per-domain local-storage directory name under the config dir. +const storageRoot = "spark" + +// checkSeg validates a value used as a single path segment (appID or key). +// It rejects empty, "..", "." , URL metacharacters, control and dangerous +// Unicode via validate.ResourceName — defense-in-depth alongside the +// EncodePathSegment escaping applied when building the path, so neither value +// can traverse out of the storage directory. +func checkSeg(name, what string) error { + if err := validate.ResourceName(name, what); err != nil { + return fmt.Errorf("apps storage: %w", err) + } + if name == "." { + return fmt.Errorf("apps storage: %s must not be \".\"", what) + } + return nil +} + +// appDir returns the storage directory for one app: ~/.lark-cli/spark// +// (workspace-aware). +func appDir(appID string) string { + return filepath.Join(core.GetConfigDir(), storageRoot, validate.EncodePathSegment(appID)) +} + +// appKeyPath returns the file path for one (appID, key). +func appKeyPath(appID, key string) string { + return filepath.Join(appDir(appID), validate.EncodePathSegment(key)) +} + +// Read returns the bytes stored under (appID, key). A missing file returns +// (nil, nil). Content is opaque — callers own the format. Note: an empty stored +// value is indistinguishable from a missing key (both yield nil), so this store +// is unsuitable as an existence flag. +func Read(appID, key string) ([]byte, error) { + if err := checkSeg(appID, "appID"); err != nil { + return nil, err + } + if err := checkSeg(key, "key"); err != nil { + return nil, err + } + data, err := vfs.ReadFile(appKeyPath(appID, key)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("apps storage: read: %w", err) + } + return data, nil +} + +// Write atomically stores data under (appID, key): file 0600, dir 0700. It is a +// create-or-replace upsert for that key; content is written verbatim in +// plaintext. 0600 only guards against other local OS users — it does not protect +// against this user's processes, backups, or synced folders. appID and key are +// opaque strings: any "/" is escaped into a single path segment, never treated +// as a directory separator. +func Write(appID, key string, data []byte) error { + if err := checkSeg(appID, "appID"); err != nil { + return err + } + if err := checkSeg(key, "key"); err != nil { + return err + } + if err := vfs.MkdirAll(appDir(appID), 0700); err != nil { + return fmt.Errorf("apps storage: create dir: %w", err) + } + if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil { + return fmt.Errorf("apps storage: write: %w", err) + } + return nil +} + +// Delete removes the file under (appID, key). A missing file is not an error. +func Delete(appID, key string) error { + if err := checkSeg(appID, "appID"); err != nil { + return err + } + if err := checkSeg(key, "key"); err != nil { + return err + } + if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("apps storage: delete: %w", err) + } + return nil +} + +// List returns the keys stored under appID, skipping subdirectories and names +// that fail to unescape. A missing app directory yields an empty list. +func List(appID string) ([]string, error) { + if err := checkSeg(appID, "appID"); err != nil { + return nil, err + } + entries, err := vfs.ReadDir(appDir(appID)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + return nil, fmt.Errorf("apps storage: read dir: %w", err) + } + keys := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + key, err := url.PathUnescape(e.Name()) + if err != nil { + continue + } + keys = append(keys, key) + } + return keys, nil +} diff --git a/shortcuts/apps/storage_test.go b/shortcuts/apps/storage_test.go new file mode 100644 index 000000000..ebc76cb17 --- /dev/null +++ b/shortcuts/apps/storage_test.go @@ -0,0 +1,219 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "os" + "path/filepath" + "runtime" + "sort" + "testing" +) + +// storageTempDir points GetConfigDir at an isolated temp dir for the test. +func storageTempDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + return dir +} + +func TestStorageWriteReadRoundTrip(t *testing.T) { + storageTempDir(t) + want := []byte(`{"username":"u","token":"t"}`) + if err := Write("app_a", "git.json", want); err != nil { + t.Fatalf("Write: %v", err) + } + got, err := Read("app_a", "git.json") + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(got) != string(want) { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestStorageReadMissingReturnsNil(t *testing.T) { + storageTempDir(t) + got, err := Read("app_a", "nope") + if err != nil { + t.Fatalf("err: %v", err) + } + if got != nil { + t.Fatalf("want nil, got %q", got) + } +} + +func TestStorageEmptyArgsRejected(t *testing.T) { + storageTempDir(t) + if _, err := Read("", "k"); err == nil { + t.Error("Read empty appID should error") + } + if _, err := Read("a", ""); err == nil { + t.Error("Read empty key should error") + } + if err := Write("", "k", nil); err == nil { + t.Error("Write empty appID should error") + } + if err := Write("a", "", nil); err == nil { + t.Error("Write empty key should error") + } + if err := Delete("", "k"); err == nil { + t.Error("Delete empty appID should error") + } + if _, err := List(""); err == nil { + t.Error("List empty appID should error") + } +} + +func TestStorageOverwrite(t *testing.T) { + storageTempDir(t) + if err := Write("app_a", "git.json", []byte("v1")); err != nil { + t.Fatalf("Write1: %v", err) + } + if err := Write("app_a", "git.json", []byte("v2")); err != nil { + t.Fatalf("Write2: %v", err) + } + got, _ := Read("app_a", "git.json") + if string(got) != "v2" { + t.Errorf("want v2, got %q", got) + } +} + +func TestStorageDeleteIdempotent(t *testing.T) { + storageTempDir(t) + if err := Write("app_a", "git.json", []byte("x")); err != nil { + t.Fatalf("Write: %v", err) + } + if err := Delete("app_a", "git.json"); err != nil { + t.Fatalf("first Delete: %v", err) + } + if got, _ := Read("app_a", "git.json"); got != nil { + t.Error("file should be gone after Delete") + } + if err := Delete("app_a", "git.json"); err != nil { + t.Errorf("second Delete should be nil (idempotent), got %v", err) + } +} + +func TestStorageListKeys(t *testing.T) { + storageTempDir(t) + for _, k := range []string{"git.json", "meta.json", "notes"} { + if err := Write("app_a", k, []byte("x")); err != nil { + t.Fatalf("Write %s: %v", k, err) + } + } + got, err := List("app_a") + if err != nil { + t.Fatalf("List: %v", err) + } + sort.Strings(got) + want := []string{"git.json", "meta.json", "notes"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestStorageListMissingAppDir(t *testing.T) { + storageTempDir(t) + got, err := List("never_written") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 0 { + t.Errorf("want empty, got %v", got) + } +} + +func TestStorageListSkipsSubdirs(t *testing.T) { + dir := storageTempDir(t) + if err := Write("app_a", "git.json", []byte("x")); err != nil { + t.Fatalf("Write: %v", err) + } + if err := os.Mkdir(filepath.Join(dir, "spark", "app_a", "sub"), 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + got, err := List("app_a") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 || got[0] != "git.json" { + t.Errorf("want [git.json], got %v", got) + } +} + +func TestStorageEscapesAppIDAndKey(t *testing.T) { + dir := storageTempDir(t) + const appID, key = "a/b", "x/y" + if err := Write(appID, key, []byte("v")); err != nil { + t.Fatalf("Write: %v", err) + } + // no path traversal: spark/ has exactly one (escaped) app dir, no nested a/b tree + entries, _ := os.ReadDir(filepath.Join(dir, "spark")) + if len(entries) != 1 { + t.Fatalf("expected 1 escaped app dir under spark/, got %v", entries) + } + got, err := Read(appID, key) + if err != nil || string(got) != "v" { + t.Fatalf("Read escaped: got %q err %v", got, err) + } + keys, err := List(appID) + if err != nil || len(keys) != 1 || keys[0] != key { + t.Fatalf("List escaped: got %v err %v", keys, err) + } +} + +func TestStorageRejectsTraversal(t *testing.T) { + dir := storageTempDir(t) + for _, bad := range []string{"..", ".", "../x", "a/../b"} { + if err := Write(bad, "k", []byte("x")); err == nil { + t.Errorf("Write appID=%q should error", bad) + } + if err := Write("app", bad, []byte("x")); err == nil { + t.Errorf("Write key=%q should error", bad) + } + if _, err := Read(bad, "k"); err == nil { + t.Errorf("Read appID=%q should error", bad) + } + if err := Delete(bad, "k"); err == nil { + t.Errorf("Delete appID=%q should error", bad) + } + if _, err := List(bad); err == nil { + t.Errorf("List appID=%q should error", bad) + } + } + // nothing escaped out of spark/ into ~/.lark-cli + if _, err := os.Stat(filepath.Join(dir, "k")); !os.IsNotExist(err) { + t.Error("traversal must not create files outside spark/") + } +} + +func TestStoragePermsAndDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("perm bits not meaningful on windows") + } + dir := storageTempDir(t) + if err := Write("app_a", "git.json", []byte("x")); err != nil { + t.Fatalf("Write: %v", err) + } + fi, err := os.Stat(filepath.Join(dir, "spark", "app_a", "git.json")) + if err != nil { + t.Fatalf("stat file: %v", err) + } + if fi.Mode().Perm() != 0600 { + t.Errorf("file perm = %o, want 0600", fi.Mode().Perm()) + } + di, err := os.Stat(filepath.Join(dir, "spark", "app_a")) + if err != nil { + t.Fatalf("stat dir: %v", err) + } + if di.Mode().Perm() != 0700 { + t.Errorf("dir perm = %o, want 0700", di.Mode().Perm()) + } +}