Compare commits

...

2 Commits

Author SHA1 Message Date
liangshuo-1
d11a6e97a4 chore: release v1.0.57 (#1553) 2026-06-23 20:43:41 +08:00
raistlin042
e4248d1154 fix: harden lark-apps +init/+html-publish and skill guidance (#1517)
* fix: reject +init into a different app's project directory

* fix: reject single HTML files larger than 10MB in +html-publish

* docs: clarify publish visibility, domain routing, and role/permission boundary
2026-06-23 20:18:10 +08:00
9 changed files with 390 additions and 18 deletions

View File

@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file.
## [v1.0.57] - 2026-06-23
### Features
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
- **base**: Support record comments (#1043)
- **search**: Surface search API notices (#1413)
### Bug Fixes
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
- **meta**: Backfill enum value descriptions from options (#1541)
- **cli**: Add missing CLI headers for git credential helper (#1539)
### Documentation
- **doc**: Refine rich block, path, and block ID guidance (#1508)
- **mail**: Trim lark-mail skill context (#1527)
- **drive**: Add permission governance workflow guidance (#1292)
### Build
- **ci**: Bind semantic review to workflow run head (#1551)
## [v1.0.56] - 2026-06-18
### Features
@@ -1212,6 +1236,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.56",
"version": "1.0.57",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
@@ -84,6 +85,9 @@ var AppsHTMLPublish = common.Shortcut{
// for dry-run "advisory preview" semantics).
dry.Set("validation_error", err.Error())
}
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
dry.Set("oversize_html", hits)
}
dry.Set("file_count", len(candidates))
var totalSize int64
names := make([]string, 0, len(candidates))
@@ -140,18 +144,22 @@ type appsHTMLPublishSpec struct {
// per-environment .env.* files for every stage).
const maxSensitiveListInError = 5
// truncatedJoin joins items with ", ", capping at max entries and appending
// "(and N more)" for the remainder, so an inline error list stays readable when
// a payload has many hits.
func truncatedJoin(items []string, max int) string {
if len(items) <= max {
return strings.Join(items, ", ")
}
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
}
// sensitiveCandidatesError builds the Validate-time rejection when --path
// contains credential files and --allow-sensitive was not set.
func sensitiveCandidatesError(hits []string) error {
var sample string
if len(hits) <= maxSensitiveListInError {
sample = strings.Join(hits, ", ")
} else {
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
}
return appsValidationParamError("--path",
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
"--path contains %d credential file(s) that should not be published: %s",
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
}
@@ -168,6 +176,30 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
// Mutable for tests.
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
// 用 var 而非 const便于单测调小覆盖拦截路径。
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html大小写不敏感且单个 Size 超过
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
var hits []string
for _, c := range candidates {
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
hits = append(hits, c.RelPath)
}
}
return hits
}
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
func oversizeHTMLFilesError(hits []string) error {
return appsValidationParamError("--path",
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
}
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
// 目录形态:根目录下必须有 index.html。
// 单文件形态:文件名必须就是 index.html。
@@ -190,6 +222,9 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
if err := ensureIndexHTML(candidates); err != nil {
return nil, err
}
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
return nil, oversizeHTMLFilesError(hits)
}
var rawTotal int64
for _, c := range candidates {
rawTotal += c.Size

View File

@@ -503,3 +503,82 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
t.Fatalf("client must not be called when raw cap hit")
}
}
func TestOversizeHTMLFiles(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
cands := []htmlPublishCandidate{
{RelPath: "index.html", Size: 50},
{RelPath: "big.html", Size: 4096},
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
{RelPath: "huge.png", Size: 9000}, // 非 .html忽略
}
hits := oversizeHTMLFiles(cands)
if len(hits) != 2 {
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
}
for _, h := range hits {
if h == "huge.png" || h == "index.html" {
t.Fatalf("unexpected hit %q", h)
}
}
}
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
}
}
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 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 per-file oversize error")
}
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when an HTML file is oversize")
}
}
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %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("non-html oversize must not be blocked by the .html cap: %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called; calls=%v", fake.calls)
}
}

View File

@@ -99,7 +99,14 @@ var AppsInit = common.Shortcut{
dry.Set("dir_error", err.Error())
dir = defaultCloneDir(appID)
} else if isAlreadyInitialized(dir) {
dry.Set("already_initialized", true)
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
if existing != "" {
dry.Set("app_id_mismatch", existing)
}
dry.Set("dir_error", e.Error())
} else {
dry.Set("already_initialized", true)
}
} else if e := ensureEmptyDir(dir); e != nil {
dry.Set("dir_error", e.Error())
}
@@ -199,6 +206,61 @@ func isAlreadyInitialized(dir string) bool {
return err == nil && !info.IsDir()
}
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id用于判断目标目录是否同一个妙搭应用。
// 返回 (appID, isSparkProject, err)
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
// - 解析成功 → (trim 后的 app_id, true, nil)app_id 缺失/为空时为 ""
func readMetaAppID(dir string) (string, bool, error) {
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil {
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
}
var m struct {
AppID string `json:"app_id"`
}
if err := json.Unmarshal(b, &m); err != nil {
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
}
return strings.TrimSpace(m.AppID), true, nil
}
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
// - 不是妙搭工程(无 meta.json → nil交给 ensureEmptyDir 判空/非空)
// - 是妙搭工程且 app_id 与 appID 一致 → nil走已初始化短路复用本地代码
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
// - meta.json 损坏/不可读,无法确认 → 报错fail closed提示换目录
//
// 返回值 existing 是目录里已存在的 app_id仅"已是另一个 app"的拒绝场景非空),供调用方在
// dry-run 里回填 app_id_mismatch避免二次读 meta.json。
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
existing, isSpark, readErr := readMetaAppID(dir)
if readErr != nil {
return "", appsValidationParamError("--dir",
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
dir, metaRelPath, appID).
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
WithCause(readErr)
}
if !isSpark || existing == appID {
return existing, nil
}
if existing == "" {
// meta 存在但缺 app_id更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
return "", appsValidationParamError("--dir",
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
dir, metaRelPath, appID).
WithHint("remove the directory and re-run +init, or choose a different --dir")
}
return existing, appsValidationParamError("--dir",
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
dir, existing, appID).
WithHint("choose a different --dir (or cd into the matching project) before running +init")
}
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
// the file does not exist, this is a no-op (we never create it).
@@ -378,6 +440,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
return err
}
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.

View File

@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
@@ -394,6 +394,40 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
}
}
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
dir := relCloneDir(t)
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("mismatched app_id must error")
}
problem := requireAppsValidationProblem(t, err)
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--dir" {
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
}
if !strings.Contains(problem.Message, "different app") {
t.Fatalf("message=%q, want 'different app'", problem.Message)
}
for _, c := range f.calls {
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
}
}
}
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("http://u:t@h/app_x.git"),
@@ -1468,6 +1502,125 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
}
}
func TestReadMetaAppID(t *testing.T) {
writeMeta := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// 不存在 meta.json → ("", false, nil)
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
}
// 存在且有 app_id → (app_id, true, nil)
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
}
// 存在但 app_id 空 → ("", true, nil)
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
}
// 存在但坏 JSON → ("", false, err)(无法确认)
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
}
}
func TestEnsureInitDirMatchesApp(t *testing.T) {
writeMeta := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// 无 meta非妙搭工程→ nil交给 ensureEmptyDir
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
t.Fatalf("no meta should pass: %v", err)
}
// 同 app_id → (app_id, nil)(走已初始化短路)
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
}
// 不同 app_id → error换目录返回 existing=app_other断言 typed metadatasubtype/param
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
if errMismatch == nil {
t.Fatal("different app should error")
}
if existing != "app_other" {
t.Fatalf("mismatch should return existing app_id, got %q", existing)
}
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(errMismatch, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
}
if ve.Param != "--dir" {
t.Fatalf("param=%q, want --dir", ve.Param)
}
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
}
if !strings.Contains(problem.Hint, "different --dir") {
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
}
// 空 app_id缺 app_id 标记的半成品)→ error独立文案非 "different app"),返回 existing=""
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
if errEmpty == nil {
t.Fatal("empty meta app_id should error (cannot confirm same app)")
}
if emptyExisting != "" {
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
}
pEmpty := requireAppsValidationProblem(t, errEmpty)
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(pEmpty.Message, "without an app_id") {
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
}
if strings.Contains(pEmpty.Message, "different app") {
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
}
// meta 损坏/不可读 → errorfail closed返回 existing=""
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
if errBad == nil {
t.Fatal("corrupted meta should fail closed")
}
if badExisting != "" {
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
}
pBad := requireAppsValidationProblem(t, errBad)
if pBad.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
}
var veBad *errs.ValidationError
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
}
}
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
// classification of an external-tool failure: a failing git subprocess
// surfaces as internal/external_tool with the cause preserved.

View File

@@ -1,7 +1,7 @@
---
name: lark-apps
version: 1.0.0
description: "妙搭Spark/Miaoda应用开发与托管应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传lark-drive、飞书文档编辑lark-doc、原生幻灯片创建lark-slides。"
description: "妙搭Spark/Miaoda应用开发与托管应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud、应用数据库、可见范围时使用。不负责普通云盘文件上传lark-drive、飞书文档编辑lark-doc、原生幻灯片创建lark-slides。"
metadata:
requires:
bins: ["lark-cli"]
@@ -48,8 +48,14 @@ metadata:
- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。
- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}` 仅进编辑态,不能顶替发布当分享链接。
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`:进应用编辑/开发态、管理与继续开发应用的入口。发布成功后,连同发布态链接一并提供给用户(说明"管理 / 继续开发去这里");但它仅进编辑态,**不能**顶替发布态链接当分享链接。
- 发布态链接来源html → `+html-publish``data.url`;全栈 → `+release-get` 轮询 `finished``online_url` / `failed``error_logs`
- **可见范围**发布态链接html 的 `data.url`、全栈的 `online_url`)默认仅**创建者可见**,发给他人对方会无权限打不开。当可分享链接交付给用户前,先告知当前仅本人可见,再询问是否用 `+access-scope-set``tenant`/`public`/`specific`)放开(可先 `+access-scope-get` 查当前范围)。
## 能力边界
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限/ 自动化 / 插件。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。
- 用户要配置权限 / 自动化 / 插件时,引导其使用开发态连接前往云端开发(妙搭 web处理。
## app_id 获取
@@ -69,4 +75,4 @@ metadata:
## 高影响动作:确认与预授权
- **预授权判定**:判断用户是否表达了"放手做完、不用中途逐步问我"的意图——明确免确认(如"别问 / 直接做 / 自己定"),或要求一气呵成做到完成(如"做完部署上线给我")。是 → 整个流程按合理默认往下走、不再逐步确认(含 clone 到派生目录、发布等);否 → 缺失参数(如目录)该问就问、高影响动作先确认。
- **不豁免底线**会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md)即便已预授权,也`--dry-run` 确认。
- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项

View File

@@ -11,7 +11,7 @@
- 必填:`--app-id``--path`
- `--path` 可以是单个文件或目录;入口必须是 `index.html`
- 可选:`--allow-sensitive`,跳过凭据文件扫描。
- 客户端打包 tar.gz 上传发布;压缩包上限当前为 20MB未压缩候选文件总量也有保护上限
- 客户端打包 tar.gz 上传发布。三条硬性大小限制,任一超限即被客户端拒绝、无法发布:单个 `.html` 文件 ≤ 10MB、打包后 tar.gz ≤ 20MB未压缩候选文件总量 ≤ 200MB
## 示例
@@ -33,12 +33,19 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
- 发布态访问链接以本命令成功返回的 `data.url` 为准。
- 重新发布前,`+list``is_published=true` 只能说明历史上发布过,不代表当前本地产物已经部署。
## 发布前置门(第一步,先于任何其他动作)
收到发布意图后,第一个动作是量三个尺寸,不是读文件内容、不是打包:
1. 单个 `.html` ≤ 10MB / tar.gz ≤ 20MB / 未压缩总量 ≤ 200MB。
2. 任一超限 → 立即 STOP把超限数字转述给用户交还决定权。
3. 三项都通过 → 才进入下面的命令骨架。
## 预览与发布边界
- 用户只说“用 HTML 写个 PPT/页面给我看看”时,先生成本地文件或目录,返回路径并问是否发布到妙搭分享;不要默认创建应用或部署。
- 用户明确说“部署出去/发链接/可分享”时,才创建 `html` 应用并用 `+html-publish`
- 用户要发布但没有 app_id 时,先 `+create --app-type html` 创建应用;应用名可从页面/站点主题生成,不要让用户手动提供 app_id。
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist``.git` 目录会被自动排除,不会进入压缩包`node_modules`、源码缓存等仍建议手动精简以控制包体
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist``.git` 目录会被自动排除,不会进入压缩包。
- 重新部署同一个 HTML 应用时复用原 `app_id`,只重新执行 `+html-publish --app-id <id> --path <dir-or-index.html>`
## 安全规则
@@ -48,4 +55,3 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
## 常见失败
- 缺少 `index.html`:目录根放置 `index.html`,或单文件路径直接指向名为 `index.html` 的文件。
- 包体过大:让用户精简 `--path`,不要把源码、依赖目录、构建缓存一起发布。

View File

@@ -31,6 +31,7 @@ lark-cli apps +init --app-id app_xxx --dir ./my-app --dry-run
## Agent 规则
- 目标目录必须不存在、为空目录,或已含 `.spark/meta.json` 的已初始化仓库。
- 目标目录必须不存在、为空目录,或已含 `.spark/meta.json` 且其 app_id 与 `--app-id` 一致的已初始化仓库。
- 目标目录已含 `.spark/meta.json` 时,`+init` 会跳过 clone/scaffold但仍执行一次 env-pull 刷新本地环境变量;告知用户“仓库已初始化,本地环境变量已刷新,可直接开发”,不要误报失败或重复 clone。
- `+init` 输出没有必要原样复述;告诉用户 clone path、分支和下一步即可。
- 新建应用做本地初始化时,若选定的目标目录已存在,不要复用,改用一个不冲突的目录名(已预授权”放手做”时自动追加后缀如 `-2`;否则向用户确认目录名)。