mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +08:00
Compare commits
2 Commits
features/F
...
v1.0.57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d11a6e97a4 | ||
|
|
e4248d1154 |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 metadata(subtype/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 损坏/不可读 → error(fail 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.
|
||||
|
||||
@@ -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)),立即停止并转述超限项。
|
||||
|
||||
@@ -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`,不要把源码、依赖目录、构建缓存一起发布。
|
||||
|
||||
@@ -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`;否则向用户确认目录名)。
|
||||
|
||||
Reference in New Issue
Block a user