From a3a3931baff0e8f277525a213e46eb15205fe4f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=A8=E6=9D=89?= Date: Mon, 31 Aug 2026 20:20:21 +0800 Subject: [PATCH 1/4] feat(apps): add +export to download an app's source archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `lark-cli apps +export`, which downloads an app's source code as a zip. Why this is not just `+init`: `+init` clones the app's git repository, so it requires repository access. A creative app shared with you via a share link points at someone else's app, and cloning it is not possible. `+export` only requires download permission on the app itself, so it is the only path that works across apps. Accordingly `--app-id` and `--meta-token` (the share-link token) are mutually exclusive and exactly one is required. Streaming rather than buffering: the endpoint returns a raw binary body through the gateway, so the response is streamed straight to disk via FileIO().Save instead of being read into memory. Archive size is unbounded in practice (images, media and build output all count), so buffering would scale with the repository. Error taxonomy: the stream client cannot inspect a JSON envelope on a binary response and classifies every 4xx as a transport-level NetworkError. That is misleading here, so failures are re-typed onto the taxonomy an agent can act on while preserving the original cause. The distinguishing case is 422: apps whose code lives outside git keep their artifacts in file storage, which no retry or permission change will fix, so the hint points at +file-list / +file-download. Docs note that the export reflects the last commit, not the sandbox working tree — the server runs git archive against the remote and never reads the sandbox, so uncommitted sandbox edits are absent by design. Tests cover the flag XOR, output traversal rejection, dry-run shape, streaming to disk, Content-Disposition naming, share-token requests, and each mapped failure status (including that a failed export leaves no partial file). --- shortcuts/apps/apps_export.go | 220 ++++++++++++++++ shortcuts/apps/apps_export_test.go | 237 ++++++++++++++++++ shortcuts/apps/shortcuts.go | 1 + shortcuts/apps/shortcuts_test.go | 10 +- skills/lark-apps/SKILL.md | 1 + .../lark-apps/references/lark-apps-export.md | 60 +++++ 6 files changed, 524 insertions(+), 5 deletions(-) create mode 100644 shortcuts/apps/apps_export.go create mode 100644 shortcuts/apps/apps_export_test.go create mode 100644 skills/lark-apps/references/lark-apps-export.md diff --git a/shortcuts/apps/apps_export.go b/shortcuts/apps/apps_export.go new file mode 100644 index 0000000000..5da1710c7e --- /dev/null +++ b/shortcuts/apps/apps_export.go @@ -0,0 +1,220 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// AppsExport downloads an app's source code as a zip archive. +// +// The response is a raw binary stream from the gateway (not a signed URL), so the +// body is streamed straight to disk instead of being buffered in memory. +var AppsExport = common.Shortcut{ + Service: appsService, + Command: "+export", + Description: "Export an app's source code as a zip archive", + Risk: "read", + Tips: []string{ + "Exports the last commit on the app's default branch, not the sandbox working tree: changes made in the sandbox without a checkpoint are not included.", + "Example: lark-cli apps +export --app-id --output ./src.zip", + "Example (share token): lark-cli apps +export --meta-token # for an app shared with you; you still need download permission", + "Example (omit --output): lark-cli apps +export --app-id # saves to ./.zip", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id (exactly one of --app-id / --meta-token)"}, + {Name: "meta-token", Desc: "share-link token of a creative app (exactly one of --app-id / --meta-token)"}, + {Name: "checkpoint-id", Desc: "checkpoint id to export (default: latest commit on the default branch)"}, + {Name: "output", Desc: "local output path (default: .zip in cwd)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := requireExactlyOneExportSource(rctx); err != nil { + return err + } + if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" { + if _, err := requireAppID(appID); err != nil { + return err + } + } + return rejectOutputTraversal(rctx.Str("output")) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET(exportPath(exportLookup(rctx))). + Desc("Download the app source archive and save it to --output"). + Params(exportQueryParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := requireExactlyOneExportSource(rctx); err != nil { + return err + } + + apiPath := exportPath(exportLookup(rctx)) + query := url.Values{} + for k, v := range exportQueryParams(rctx) { + query.Set(k, fmt.Sprintf("%v", v)) + } + if encoded := query.Encode(); encoded != "" { + apiPath += "?" + encoded + } + resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: apiPath, + }) + if err != nil { + return classifyExportErr(err) + } + defer resp.Body.Close() + + out := strings.TrimSpace(rctx.Str("output")) + if out == "" { + out = defaultExportFilename(resp, rctx) + } + saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output").WithCause(err) + } + resolved, perr := rctx.FileIO().ResolvePath(out) + if perr != nil || resolved == "" { + resolved = out + } + + result := map[string]interface{}{ + "output": resolved, + "size_bytes": saved.Size(), + } + if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" { + result["app_id"] = appID + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Saved %s (%d bytes)\n", resolved, saved.Size()) + }) + return nil + }, +} + +// requireExactlyOneExportSource enforces the app-id / meta-token XOR. +// +// Both empty or both set is a user error the server would also reject; failing +// here keeps the message specific about which flags conflict. +func requireExactlyOneExportSource(rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + metaToken := strings.TrimSpace(rctx.Str("meta-token")) + switch { + case appID == "" && metaToken == "": + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "one of --app-id / --meta-token is required"). + WithHint("pass --app-id for an app you own, or --meta-token from a share link") + case appID != "" && metaToken != "": + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--app-id and --meta-token are mutually exclusive"). + WithParam("--meta-token") + } + return nil +} + +// exportLookup returns the path-segment locator: --app-id and --meta-token share +// one segment and the server tells them apart by the "app_" prefix, matching how +// +get already accepts either identifier. +// +// Callers must run requireExactlyOneExportSource first, so exactly one is set. +func exportLookup(rctx *common.RuntimeContext) string { + if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" { + return appID + } + return strings.TrimSpace(rctx.Str("meta-token")) +} + +// exportPath builds the archive endpoint for a locator. +// +// The locator is a path segment rather than a query parameter: the gateway +// already routes GET /apps/:appID, so a static segment such as /apps/code_archive +// would be swallowed by it and a missing route registration would surface as +// "app not found" instead of a 404. +func exportPath(lookup string) string { + return fmt.Sprintf("%s/apps/%s/code-archive", apiBasePath, validate.EncodePathSegment(lookup)) +} + +// exportQueryParams builds the request params shared by DryRun and Execute so the +// dry-run output cannot drift from the real call. +func exportQueryParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{} + if checkpointID := strings.TrimSpace(rctx.Str("checkpoint-id")); checkpointID != "" { + params["checkpoint_id"] = checkpointID + } + return params +} + +// classifyExportErr re-types the archive endpoint's HTTP failures. +// +// This endpoint returns a raw binary body, so the stream client cannot inspect a +// JSON envelope and classifies every 4xx as a transport-level NetworkError. That +// is wrong for the cases below: they are not transport problems and retrying will +// never help. Re-map them onto the taxonomy an agent can act on, keeping the +// original error as the cause. 422 is the distinguishing case — the app's code is +// not stored in git at all (static HTML apps keep artifacts in file storage), so +// the hint points at the interface that can actually serve it. +func classifyExportErr(err error) error { + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + return err + } + detail := netErr.Message + switch netErr.Code { + case http.StatusUnauthorized: + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "export failed: %s", detail). + WithHint("run: lark-cli auth login"). + WithCause(err) + case http.StatusForbidden: + return errs.NewPermissionError(errs.SubtypePermissionDenied, "export failed: %s", detail). + WithHint("you need download permission on this app; holding a share token is not enough"). + WithCause(err) + case http.StatusNotFound: + return errs.NewAPIError(errs.SubtypeNotFound, "export failed: %s", detail). + WithHint(appIDListHint). + WithCause(err) + case http.StatusUnprocessableEntity: + return errs.NewAPIError(errs.SubtypeUnknown, "export failed: %s", detail). + WithHint("this app type keeps its code outside git; use the file storage commands (+file-list / +file-download) to fetch its artifacts"). + WithCause(err) + case http.StatusRequestEntityTooLarge: + return errs.NewAPIError(errs.SubtypeUnknown, "export failed: %s", detail). + WithHint("the archive exceeds the export size limit; clone the repository with +git-credential-init instead"). + WithCause(err) + default: + // 5xx and genuine transport failures keep the client's classification, + // including its retryable flag and log id. + return err + } +} + +// defaultExportFilename derives the save path when --output is omitted, preferring +// the server's Content-Disposition so the archive keeps its canonical name. +func defaultExportFilename(resp *http.Response, rctx *common.RuntimeContext) string { + if name := common.ResolveDownloadFileName(resp.Header, ""); name != "" { + return name + } + if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" { + return appID + ".zip" + } + return "app-source.zip" +} diff --git a/shortcuts/apps/apps_export_test.go b/shortcuts/apps/apps_export_test.go new file mode 100644 index 0000000000..3ec9623872 --- /dev/null +++ b/shortcuts/apps/apps_export_test.go @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +// exportURL builds the archive endpoint for a locator. The locator is a path +// segment shared by --app-id and --meta-token, so the URL varies per case. +func exportURL(lookup string) string { + return "/open-apis/spark/v1/apps/" + lookup + "/code-archive" +} + +// archiveStub serves a raw zip body the way the gateway does for this endpoint. +// lookup is the path segment the request is expected to target. +func archiveStub(lookup string, status int, body []byte, contentType, disposition string) *httpmock.Stub { + headers := http.Header{} + headers.Set("Content-Type", contentType) + if disposition != "" { + headers.Set("Content-Disposition", disposition) + } + return &httpmock.Stub{ + Method: "GET", URL: exportURL(lookup), Status: status, RawBody: body, Headers: headers, + } +} + +// TestAppsExport_RequiresExactlyOneSource pins the --app-id / --meta-token XOR. +func TestAppsExport_RequiresExactlyOneSource(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"neither", []string{"+export", "--as", "user"}}, + {"both", []string{"+export", "--app-id", "app_x", "--meta-token", "tok", "--as", "user"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsExport, c.args, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + }) + } +} + +// TestAppsExport_RejectsOutputTraversal keeps writes inside the working directory. +func TestAppsExport_RejectsOutputTraversal(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "../escape.zip", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--output" { + t.Fatalf("Param = %q, want --output", ve.Param) + } +} + +// TestAppsExport_DryRun asserts the method, URL and params without a real call. +func TestAppsExport_DryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--checkpoint-id", "42", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env dryRunAPIEnvelope + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Method != "GET" || env.API[0].URL != exportURL("app_x") { + t.Fatalf("dry-run = %s %s, want GET %s", env.API[0].Method, env.API[0].URL, exportURL("app_x")) + } + out := stdout.String() + for _, want := range []string{"app_x", "42"} { + if !strings.Contains(out, want) { + t.Errorf("dry-run output missing %q\n%s", want, out) + } + } +} + +// TestAppsExport_StreamsArchiveToDisk is the happy path: raw body lands on disk. +func TestAppsExport_StreamsArchiveToDisk(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte("ZIPDATA"), "application/octet-stream", "")) + + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "src.zip", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + b, err := os.ReadFile(filepath.Join(dir, "src.zip")) + if err != nil { + t.Fatalf("read output file: %v", err) + } + if string(b) != "ZIPDATA" { + t.Fatalf("archive content = %q, want ZIPDATA", b) + } + if !strings.Contains(stdout.String(), `"size_bytes": 7`) { + t.Errorf("output json missing size_bytes:7\n%s", stdout.String()) + } +} + +// TestAppsExport_DefaultsOutputToContentDisposition prefers the server-provided +// filename so the archive keeps its canonical name. +func TestAppsExport_DefaultsOutputToContentDisposition(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte("ZIP"), "application/octet-stream", `attachment; filename="app_x.zip"`)) + + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if _, err := os.Stat(filepath.Join(dir, "app_x.zip")); err != nil { + t.Fatalf("expected app_x.zip from Content-Disposition: %v", err) + } +} + +// TestAppsExport_MetaTokenSource sends the share token instead of an app id. +// +// The token occupies the same path segment an app id would — the server tells +// them apart by the "app_" prefix — so this also pins that neither source is +// ever sent as a query parameter. +func TestAppsExport_MetaTokenSource(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + var gotURL string + stub := archiveStub("share-tok", 200, []byte("ZIP"), "application/octet-stream", "") + stub.OnMatch = func(req *http.Request) { gotURL = req.URL.String() } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--meta-token", "share-tok", "--output", "s.zip", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(gotURL, "/apps/share-tok/code-archive") { + t.Fatalf("request URL = %q, want the token in the locator path segment", gotURL) + } + if strings.Contains(gotURL, "meta_token=") || strings.Contains(gotURL, "app_id=") { + t.Errorf("request URL = %q, must not carry the locator as a query parameter", gotURL) + } +} + +// errHint pulls the recovery hint off whichever typed error this endpoint returned. +func errHint(err error) string { + var ae *errs.APIError + if errors.As(err, &ae) { + return ae.Hint + } + var pe *errs.PermissionError + if errors.As(err, &pe) { + return pe.Hint + } + var ne *errs.NetworkError + if errors.As(err, &ne) { + return ne.Hint + } + var authErr *errs.AuthenticationError + if errors.As(err, &authErr) { + return authErr.Hint + } + return "" +} + +// TestAppsExport_ClassifiesFailures asserts the typed error and, for the two +// cases an agent cannot otherwise recover from, that the hint says what to do +// instead. 422 is the static-HTML gate: the code is not in git at all, so the +// hint must point at file storage rather than suggest a retry. +func TestAppsExport_ClassifiesFailures(t *testing.T) { + cases := []struct { + name string + status int + body string + assert func(error) bool + wantHint string + }{ + {"unauthorized", 401, "auth info is empty", func(e error) bool { + var t *errs.AuthenticationError + return errors.As(e, &t) + }, ""}, + {"forbidden", 403, "permission denied", func(e error) bool { + var t *errs.PermissionError + return errors.As(e, &t) + }, "download permission"}, + {"not found", 404, "app not found", func(e error) bool { + var t *errs.APIError + return errors.As(e, &t) + }, ""}, + {"code not in git", 422, "this app type stores code outside git", func(e error) bool { + var t *errs.APIError + return errors.As(e, &t) + }, "file storage"}, + {"too large", 413, "archive too large", func(e error) bool { + var t *errs.APIError + return errors.As(e, &t) + }, "git-credential-init"}, + {"server error", 500, "boom", func(e error) bool { + var t *errs.NetworkError + return errors.As(e, &t) + }, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", c.status, []byte(c.body), "text/plain", "")) + + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "o.zip", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("HTTP %d: expected an error", c.status) + } + if !c.assert(err) { + t.Fatalf("HTTP %d: err = %T (%v), wrong typed error", c.status, err, err) + } + if c.wantHint != "" && !strings.Contains(errHint(err), c.wantHint) { + t.Errorf("HTTP %d: hint = %q, want it to mention %q", c.status, errHint(err), c.wantHint) + } + // A failed export must not leave a partial file behind. + if _, statErr := os.Stat("o.zip"); statErr == nil { + t.Errorf("HTTP %d: output file was written despite failure", c.status) + } + }) + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index fb08c062c3..6de845fdfa 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -34,6 +34,7 @@ func Shortcuts() []common.Shortcut { AppsMemberSettingsSet, AppsHTMLPublish, AppsInit, + AppsExport, AppsReleaseCreate, AppsReleaseList, AppsReleaseGet, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index a8dec3717a..204bedbfa0 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -10,7 +10,7 @@ import ( ) // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 -// 6 基础 + 1 init + 3 publish + 1 env-pull +// 6 基础 + 1 init + 1 export + 3 publish + 1 env-pull // - 6 observability(log-list/log-get/trace-list/trace-get/metric-list/analytics-list) // - 3 env(list/set/delete) // - 23 db(table-list/table-schema/sql/dev-init/data-import/data-export/sync create/list/get/enable/disable/update/delete/changelog-list/ @@ -26,11 +26,11 @@ import ( // - 9 role(role CRUD + role-member list/add/remove + role-match-list) // - 6 creative app member/permission settings // - 7 db-sync(create/list/get/enable/disable/update/delete) -// - 1 user-id-convert = 96。 -func TestAppsShortcuts_Returns96(t *testing.T) { +// - 1 user-id-convert = 97。 +func TestAppsShortcuts_Returns97(t *testing.T) { got := Shortcuts() - if len(got) != 96 { - t.Fatalf("Shortcuts() returned %d entries, want 96", len(got)) + if len(got) != 97 { + t.Fatalf("Shortcuts() returned %d entries, want 97", len(got)) } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 58a4b1776b..461cb32bfa 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -35,6 +35,7 @@ lark-cli auth login --domain apps | HTML 应用 / 创意模式 — 写 HTML 页面/网站、静态页、PPT/deck、落地页、仪表盘、UI mockup、原型、线框图、视觉探索 | 加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) | [`creative-design/creative-design.md`](creative-design/creative-design.md) | | 旧版存量 HTML 应用(无 Git 管理)继续上传已有静态产物 | `+html-publish`(仅兼容旧链路;新建 html / 创意模式 / creative-design 产物不得使用) | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | | 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md),含端到端流程和领域规则 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | +| 只要一份源码快照、不做本地开发;或要取**别人分享给你的**应用源码(你对其仓库无权限) | `+export`(下载 zip;不配 git 凭证、不建工作区)。要继续开发用 `+init` 而非本命令 | [`lark-apps-export.md`](references/lark-apps-export.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | | 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) | | 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | diff --git a/skills/lark-apps/references/lark-apps-export.md b/skills/lark-apps/references/lark-apps-export.md new file mode 100644 index 0000000000..268e8fdbe8 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-export.md @@ -0,0 +1,60 @@ +# apps +export + +`+export` 把妙搭应用的源码打成 zip 下载到本地。运行时命令事实以 `lark-cli apps +export --help` 为准。 + +## 何时用 + +只要一份源码快照的场景:读代码、审计、归档、做静态分析、把源码喂给别的工具。 + +**跨应用是它相对 `+init` 的核心价值**:创意应用的分享链接(`/page/`)指向别人的应用,你对那个仓库没有权限,`git clone` 走不通;`+export` 只要求你对该应用有下载权限。 + +## 不要用它的时候 + +要继续开发就用 `+init`,不要用 `+export` 再手动 `git init`。两者产出不同: + +| | `+export` | `+init` | +|---|---|---| +| 产出 | 一个 zip | 完整 git 工作区 | +| Git 凭证 | 不配 | 配好,可 push | +| 本地环境变量 | 不拉 | 拉 `.env.local` | +| 前提 | 对应用有下载权限 | 对**仓库**有权限 | + +用 `+export` 拿到的目录没有 git 历史、没有远端、没有凭证,改完发不回去。 + +## 导出的是「最后一次提交」,不是沙箱当前状态 + +服务端对远端仓库跑 `git archive`,从不读沙箱文件系统。用户在沙箱里改了文件但没触发 checkpoint 或发布,**那些改动不在归档里**。 + +这是设计如此,不是缺陷。若导出结果看起来"少了刚写的代码",先确认改动是否已提交,而不是重试导出。 + +## 命令骨架 + +- `--app-id` 与 `--meta-token` **恰传其一**:前者是自己的应用,后者是分享链接里的 token。 +- `--checkpoint-id` 可选,指定导出某个检查点;省略取默认分支最新提交。 +- `--output` 可选,相对当前目录;省略时用服务端给的文件名(通常是 `.zip`)。 + +## 示例 + +```bash +lark-cli apps +export --app-id app_xxx --output ./src.zip +lark-cli apps +export --app-id app_xxx # 存成 ./app_xxx.zip +lark-cli apps +export --meta-token # 别人分享给你的应用 +lark-cli apps +export --app-id app_xxx --checkpoint-id 42 +lark-cli apps +export --app-id app_xxx --dry-run +``` + +## 输出契约 + +- 成功时 stdout 是 JSON envelope,含 `output`(落盘的绝对路径)与 `size_bytes`;传了 `--app-id` 时还会回显 `app_id`。 +- 归档以流式写盘,不会整包驻留内存,大仓库也安全。 +- 失败时不会留下半个文件。 + +## 错误处理 + +| 情况 | 怎么办 | +|---|---| +| 提示代码不在 git(422) | 该应用类型(存量静态 HTML)的产物存在文件存储里,不在 git。改用 `+file-list` / `+file-download`,重试无用 | +| 权限不足(403) | 你需要该应用的下载权限。**持有分享 token 不等于有权限** | +| 应用不存在(404) | 用 `+list --keyword ` 核对 app_id | +| 归档过大(413) | 超出导出体积上限,改用 `+git-credential-init` + 原生 git clone | +| 参数报错 | `--app-id` 与 `--meta-token` 只能给一个,且必须给一个 | From 95542cd2a848a98a1b4aee96bbc6de1e519a4f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=A8=E6=9D=89?= Date: Tue, 1 Sep 2026 18:05:22 +0800 Subject: [PATCH 2/4] fix(apps): reject a JSON error envelope instead of saving it as the archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway reports several failures as HTTP 200 carrying {"code":...,"msg":...}. DoStream only intercepts status >= 400, so the envelope was streamed to disk as the "archive" and the command reported success — the caller ended up with a .zip that is really a 300-byte JSON blob. That is worse than a plain failure: nothing looks wrong until the file is opened. Gate the body on Content-Type before saving, treating an absent type as suspect the way client.HandleResponse already does, and route the envelope through the shared classifier so it surfaces as the same typed error a non-streaming command would raise. Observed against this endpoint on a test lane. --- shortcuts/apps/apps_export.go | 73 ++++++++++++++++++++++++++++-- shortcuts/apps/apps_export_test.go | 44 ++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/shortcuts/apps/apps_export.go b/shortcuts/apps/apps_export.go index 5da1710c7e..79036c333c 100644 --- a/shortcuts/apps/apps_export.go +++ b/shortcuts/apps/apps_export.go @@ -14,11 +14,22 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/recovery" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) +// exportScope is the scope this command needs. It is named once so the +// declared Scopes and the authorization fact attached on a 401 cannot drift. +const exportScope = "spark:app:read" + +// maxExportEnvelopeBytes bounds how much of a suspected JSON error envelope is +// read before classification. It matches the limit DoStream already applies to +// the error bodies it reads for status >= 400. +const maxExportEnvelopeBytes = 4096 + // AppsExport downloads an app's source code as a zip archive. // // The response is a raw binary stream from the gateway (not a signed URL), so the @@ -34,7 +45,7 @@ var AppsExport = common.Shortcut{ "Example (share token): lark-cli apps +export --meta-token # for an app shared with you; you still need download permission", "Example (omit --output): lark-cli apps +export --app-id # saves to ./.zip", }, - Scopes: []string{"spark:app:read"}, + Scopes: []string{exportScope}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ @@ -82,6 +93,10 @@ var AppsExport = common.Shortcut{ } defer resp.Body.Close() + if err := rejectExportErrorEnvelope(rctx, resp); err != nil { + return err + } + out := strings.TrimSpace(rctx.Str("output")) if out == "" { out = defaultExportFilename(resp, rctx) @@ -181,9 +196,14 @@ func classifyExportErr(err error) error { detail := netErr.Message switch netErr.Code { case http.StatusUnauthorized: - return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "export failed: %s", detail). - WithHint("run: lark-cli auth login"). - WithCause(err) + // Hand back the scope as a structured fact rather than a literal login + // command: the root presenter renders recovery, and a reduced + // distribution may not carry the command this text would name. Same + // shape the git-credential path already uses. + return recovery.Attach( + errs.NewAuthenticationError(errs.SubtypeTokenMissing, "export failed: %s", detail).WithCause(err), + recovery.UserAuthorization(exportScope), + ) case http.StatusForbidden: return errs.NewPermissionError(errs.SubtypePermissionDenied, "export failed: %s", detail). WithHint("you need download permission on this app; holding a share token is not enough"). @@ -207,6 +227,51 @@ func classifyExportErr(err error) error { } } +// rejectExportErrorEnvelope fails the export when the body is a JSON error +// envelope rather than the archive. +// +// The stream client only intercepts status >= 400, but the OpenAPI gateway +// reports several failures as HTTP 200 carrying {"code":...,"msg":...}. Without +// this gate the envelope is streamed to disk as the "archive" and the command +// reports success — the caller gets a .zip that is really a 300-byte JSON blob, +// which is worse than a plain failure because nothing looks wrong until it is +// opened. Observed against this endpoint on a test lane. +// +// An absent Content-Type is treated as JSON-suspect too, matching +// client.HandleResponse; a truthful archive always carries an explicit binary +// type. The body is bounded at 4 KiB, the same limit DoStream uses for the +// error bodies it reads itself. +func rejectExportErrorEnvelope(rctx *common.RuntimeContext, resp *http.Response) error { + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType != "" && !client.IsJSONContentType(strings.ToLower(contentType)) { + return nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxExportEnvelopeBytes)) + if err != nil { + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "export failed while reading the response: %s", err).WithCause(err) + } + // Route through the shared classifier so an envelope becomes the same typed + // error a non-streaming command would raise, log id and all. + if _, classifyErr := rctx.ClassifyAPIResponse(&larkcore.ApiResp{ + StatusCode: resp.StatusCode, + Header: resp.Header, + RawBody: body, + }); classifyErr != nil { + return classifyErr + } + // Parsed clean but still not an archive: refuse rather than save it. + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "export returned %q instead of an archive", contentTypeForMessage(contentType)) +} + +// contentTypeForMessage renders a missing Content-Type readably in diagnostics. +func contentTypeForMessage(contentType string) string { + if contentType == "" { + return "a body with no content type" + } + return contentType +} + // defaultExportFilename derives the save path when --output is omitted, preferring // the server's Content-Disposition so the archive keeps its canonical name. func defaultExportFilename(resp *http.Response, rctx *common.RuntimeContext) string { diff --git a/shortcuts/apps/apps_export_test.go b/shortcuts/apps/apps_export_test.go index 3ec9623872..3406a004f1 100644 --- a/shortcuts/apps/apps_export_test.go +++ b/shortcuts/apps/apps_export_test.go @@ -112,6 +112,50 @@ func TestAppsExport_StreamsArchiveToDisk(t *testing.T) { } } +// TestAppsExport_RejectsJSONEnvelopeBody pins the gateway's HTTP 200 + JSON +// error envelope: the stream client only intercepts status >= 400, so without a +// content-type gate the envelope is written to disk as the "archive" and the +// command reports success. The caller then holds an unopenable .zip. +func TestAppsExport_RejectsJSONEnvelopeBody(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, + []byte(`{"code":40400,"msg":"app not found"}`), "application/json; charset=utf-8", "")) + + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "src.zip", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatal("execute err = nil, want the envelope surfaced as an error") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("err = %T %v, want *errs.APIError carrying the envelope code", err, err) + } + if apiErr.Code != 40400 { + t.Errorf("code = %d, want 40400 from the envelope", apiErr.Code) + } + if _, statErr := os.Stat(filepath.Join(dir, "src.zip")); !os.IsNotExist(statErr) { + t.Error("src.zip was written; a JSON error envelope must never become a product") + } +} + +// TestAppsExport_RejectsEmptyContentType covers the same gate for a response +// that omits Content-Type: the repo treats an absent type as JSON-suspect +// (see client.HandleResponse), so it must not stream straight to disk either. +func TestAppsExport_RejectsEmptyContentType(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte(`{"code":40400,"msg":"app not found"}`), "", "")) + + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "src.zip", "--as", "user"}, factory, stdout); err == nil { + t.Fatal("execute err = nil, want the envelope surfaced as an error") + } + if _, statErr := os.Stat(filepath.Join(dir, "src.zip")); !os.IsNotExist(statErr) { + t.Error("src.zip was written despite an untyped JSON body") + } +} + // TestAppsExport_DefaultsOutputToContentDisposition prefers the server-provided // filename so the archive keeps its canonical name. func TestAppsExport_DefaultsOutputToContentDisposition(t *testing.T) { From 02e2ab1688fae16b34a139142ebea2246873076d Mon Sep 17 00:00:00 2001 From: zhmushan Date: Wed, 2 Sep 2026 20:27:53 +0800 Subject: [PATCH 3/4] fix(apps): reject non-archive export bodies via content-type whitelist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway returns HTTP 200 with an error body (JSON envelope or a bare text/plain reason like "permission denied") for api.raw download endpoints; the previous blacklist gate only caught JSON and empty content types, so a text/plain error was streamed to disk as the "archive" and reported success. Switch rejectExportErrorEnvelope to a whitelist: only application/octet-stream or application/zip is trusted and streamed through. Everything else is read back (bounded) and refused — JSON goes through the shared classifier, a plain text body surfaces the servers reason. Matches how db-data-export already guards the same api.raw channel client-side. Add TestAppsExport_RejectsPlainTextBodyOn200 covering the two error cases. --- shortcuts/apps/apps_export.go | 78 ++++++++++++++++++++++-------- shortcuts/apps/apps_export_test.go | 54 +++++++++++++++++++++ 2 files changed, 111 insertions(+), 21 deletions(-) diff --git a/shortcuts/apps/apps_export.go b/shortcuts/apps/apps_export.go index 79036c333c..1849a632ba 100644 --- a/shortcuts/apps/apps_export.go +++ b/shortcuts/apps/apps_export.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "net/url" "strings" @@ -16,6 +17,7 @@ import ( "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/recovery" + "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -227,43 +229,77 @@ func classifyExportErr(err error) error { } } -// rejectExportErrorEnvelope fails the export when the body is a JSON error -// envelope rather than the archive. +// rejectExportErrorEnvelope fails the export when the body is an error envelope +// rather than the archive. // // The stream client only intercepts status >= 400, but the OpenAPI gateway -// reports several failures as HTTP 200 carrying {"code":...,"msg":...}. Without -// this gate the envelope is streamed to disk as the "archive" and the command -// reports success — the caller gets a .zip that is really a 300-byte JSON blob, -// which is worse than a plain failure because nothing looks wrong until it is -// opened. Observed against this endpoint on a test lane. +// reports several failures as HTTP 200 carrying an error body — either a JSON +// envelope {"code":...,"msg":...} or, when the api.status field is not wired +// through on the gateway response, a bare text/plain line the handler produced +// (e.g. "permission denied", "app not found"). Without this gate the body is +// streamed to disk as the "archive" and the command reports success — the caller +// gets a .zip that is really a short error blob, which is worse than a plain +// failure because nothing looks wrong until it is opened. Both variants were +// observed against this endpoint on a test lane. // -// An absent Content-Type is treated as JSON-suspect too, matching -// client.HandleResponse; a truthful archive always carries an explicit binary -// type. The body is bounded at 4 KiB, the same limit DoStream uses for the -// error bodies it reads itself. +// The check is a whitelist, not a blacklist: only an explicit archive +// Content-Type (application/octet-stream / application/zip) is trusted and +// streamed straight through. Everything else — JSON, text/plain, or an absent +// Content-Type — is read back (bounded at 4 KiB, the same limit DoStream uses +// for the error bodies it reads itself) and refused, because a truthful archive +// always carries an explicit binary type. Whitelisting keeps the gate robust +// against any future error Content-Type the gateway might use. func rejectExportErrorEnvelope(rctx *common.RuntimeContext, resp *http.Response) error { contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) - if contentType != "" && !client.IsJSONContentType(strings.ToLower(contentType)) { + if isArchiveContentType(contentType) { return nil } body, err := io.ReadAll(io.LimitReader(resp.Body, maxExportEnvelopeBytes)) if err != nil { return errs.NewNetworkError(errs.SubtypeNetworkTransport, "export failed while reading the response: %s", err).WithCause(err) } - // Route through the shared classifier so an envelope becomes the same typed - // error a non-streaming command would raise, log id and all. - if _, classifyErr := rctx.ClassifyAPIResponse(&larkcore.ApiResp{ - StatusCode: resp.StatusCode, - Header: resp.Header, - RawBody: body, - }); classifyErr != nil { - return classifyErr + // A JSON body (or an absent Content-Type, treated as JSON-suspect like + // client.HandleResponse) goes through the shared classifier so an envelope + // becomes the same typed error a non-streaming command would raise, log id + // and all. + if contentType == "" || client.IsJSONContentType(strings.ToLower(contentType)) { + if _, classifyErr := rctx.ClassifyAPIResponse(&larkcore.ApiResp{ + StatusCode: resp.StatusCode, + Header: resp.Header, + RawBody: body, + }); classifyErr != nil { + return classifyErr + } + } + // Non-JSON body (or a JSON one that parsed clean but still isn't an archive). + // If the gateway handed back a short text/plain reason (the api.status-not- + // wired case: HTTP 200 + "permission denied" etc.), surface that text so the + // caller sees the server's reason rather than an opaque "not an archive". + // Fall back to the Content-Type when the body is empty or unreadable. + if msg := strings.TrimSpace(string(body)); msg != "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "export failed: %s", util.TruncateStr(msg, 500)) } - // Parsed clean but still not an archive: refuse rather than save it. return errs.NewInternalError(errs.SubtypeInvalidResponse, "export returned %q instead of an archive", contentTypeForMessage(contentType)) } +// isArchiveContentType reports whether ct is a Content-Type an export archive is +// allowed to carry. The handler emits application/octet-stream on success; +// application/zip is accepted defensively in case the gateway relabels it. +// +// The media type is parsed and matched exactly, not by substring: a substring +// check would accept a hostile/mislabeled header like +// text/plain; detail="application/zip" and stream the error body to disk as the +// "archive". Parameters (charset, etc.) are stripped before comparison. +func isArchiveContentType(ct string) bool { + mediaType, _, err := mime.ParseMediaType(ct) + if err != nil { + return false + } + return mediaType == "application/octet-stream" || mediaType == "application/zip" +} + // contentTypeForMessage renders a missing Content-Type readably in diagnostics. func contentTypeForMessage(contentType string) string { if contentType == "" { diff --git a/shortcuts/apps/apps_export_test.go b/shortcuts/apps/apps_export_test.go index 3406a004f1..acfb276dca 100644 --- a/shortcuts/apps/apps_export_test.go +++ b/shortcuts/apps/apps_export_test.go @@ -156,6 +156,60 @@ func TestAppsExport_RejectsEmptyContentType(t *testing.T) { } } +// TestAppsExport_RejectsPlainTextBodyOn200 covers the exact failure observed on +// a test lane: when the api.status response field is not wired through, the +// gateway returns HTTP 200 carrying the handler's bare text/plain reason +// ("permission denied", "app not found for the given meta_token") instead of +// mapping it to a 4xx. The whitelist gate must refuse it — a text/plain body is +// never a valid archive — and surface the server's reason rather than saving it. +func TestAppsExport_RejectsPlainTextBodyOn200(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {"permission denied", "permission denied"}, + {"meta token not found", "app not found for the given meta_token"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte(tc.body), "text/plain; charset=utf-8", "")) + + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "src.zip", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatal("execute err = nil, want the plain-text error surfaced") + } + if !strings.Contains(err.Error(), tc.body) { + t.Errorf("err = %v, want it to carry the server reason %q", err, tc.body) + } + if _, statErr := os.Stat(filepath.Join(dir, "src.zip")); !os.IsNotExist(statErr) { + t.Error("src.zip was written; a text/plain error body must never become a product") + } + }) + } +} + +// TestAppsExport_RejectsSpoofedArchiveContentType guards the media-type match: +// a hostile/mislabeled header like text/plain; detail="application/zip" must not +// pass the archive whitelist via substring matching. Only the exact media type +// (parameters stripped) counts, so this error body is refused, not saved. +func TestAppsExport_RejectsSpoofedArchiveContentType(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte("permission denied"), + `text/plain; detail="application/zip"`, "")) + + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "src.zip", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatal("execute err = nil, want the spoofed-content-type body refused") + } + if _, statErr := os.Stat(filepath.Join(dir, "src.zip")); !os.IsNotExist(statErr) { + t.Error("src.zip was written; application/zip inside a text/plain parameter must not pass the whitelist") + } +} + // TestAppsExport_DefaultsOutputToContentDisposition prefers the server-provided // filename so the archive keeps its canonical name. func TestAppsExport_DefaultsOutputToContentDisposition(t *testing.T) { From 7a9e5ebabef5edb13e6d656f2470048514c8704d Mon Sep 17 00:00:00 2001 From: shengdongyc Date: Fri, 4 Sep 2026 16:06:17 +0800 Subject: [PATCH 4/4] fix(apps): validate the +export locator shape and checkpoint id (#2621) The locator goes into a path segment, so a full URL is percent-encoded and sent as-is; the server then answers "app not found for the given meta_token". That 404 reads as "wrong app" and sends the caller off verifying app ids instead of trimming the URL, which is what the reference doc tells them to do on a 404. Reject the URL shape locally with a hint that names the segment to keep, on whichever flag carried the locator. --checkpoint-id was forwarded as a raw string and would fail during i64 binding at the gateway with a message that does not name the flag. Zero and negatives are rejected too: the server reads 0 as "latest", so passing it explicitly would silently ignore the flag the caller just set. Both checks run from one entry point shared by the Validate hook and Execute, keeping the pre-existing assumption that a direct Execute call re-validates its own flags. The locator is deliberately NOT checked for the "app_" prefix: this endpoint takes an app id or a meta token in the same path segment and tells them apart server-side, exactly like +get, whose --app-id is documented as "app ID or meta token". A test pins that a token given to --app-id still works, so the CLI cannot drift into being stricter than the API. --- shortcuts/apps/apps_export.go | 87 +++++++++++++++++-- shortcuts/apps/apps_export_test.go | 87 +++++++++++++++++++ .../lark-apps/references/lark-apps-export.md | 8 +- 3 files changed, 173 insertions(+), 9 deletions(-) diff --git a/shortcuts/apps/apps_export.go b/shortcuts/apps/apps_export.go index 1849a632ba..5ad5fa7430 100644 --- a/shortcuts/apps/apps_export.go +++ b/shortcuts/apps/apps_export.go @@ -11,10 +11,12 @@ import ( "mime" "net/http" "net/url" + "strconv" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/charcheck" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/recovery" "github.com/larksuite/cli/internal/util" @@ -57,14 +59,9 @@ var AppsExport = common.Shortcut{ {Name: "output", Desc: "local output path (default: .zip in cwd)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - if err := requireExactlyOneExportSource(rctx); err != nil { + if err := validateExportFlags(rctx); err != nil { return err } - if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" { - if _, err := requireAppID(appID); err != nil { - return err - } - } return rejectOutputTraversal(rctx.Str("output")) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { @@ -74,7 +71,7 @@ var AppsExport = common.Shortcut{ Params(exportQueryParams(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - if err := requireExactlyOneExportSource(rctx); err != nil { + if err := validateExportFlags(rctx); err != nil { return err } @@ -129,6 +126,82 @@ var AppsExport = common.Shortcut{ }, } +// validateExportFlags is the single flag-validation entry point, shared by the +// Validate hook and Execute so a direct Execute call (as in tests, and as the +// pre-existing XOR re-check already assumed) cannot skip a check. +func validateExportFlags(rctx *common.RuntimeContext) error { + if err := requireExactlyOneExportSource(rctx); err != nil { + return err + } + if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" { + if _, err := requireAppID(appID); err != nil { + return err + } + } + // The locator is deliberately NOT checked for the "app_" prefix: this endpoint + // accepts an app id or a meta token in the same path segment and tells them + // apart server-side, exactly like +get (whose --app-id is documented as "app ID + // or meta token"). validateRealAppID belongs to the commands whose server side + // only accepts a real app id (+init / +html-publish / +release-*), not here. + if err := validateExportLocatorShape(rctx); err != nil { + return err + } + return validateExportCheckpointID(rctx.Str("checkpoint-id")) +} + +// validateExportLocatorShape rejects a share link passed where a bare identifier +// is expected, whichever flag carried it. +// +// The locator goes into a path segment, so a full URL is percent-encoded and sent +// as-is; the server then fails to resolve it and answers "app not found for the +// given meta_token". That reads as "wrong app" and sends the caller off to verify +// an app id, when the actual fix is to pass only the segment. Catching the +// shape here turns a misleading 404 into a precise, actionable local error. +// +// This checks the character shape only — never whether the value is an app id or a +// token. That distinction is the server's (see validateExportFlags). +func validateExportLocatorShape(rctx *common.RuntimeContext) error { + param := "--app-id" + value := strings.TrimSpace(rctx.Str("app-id")) + if value == "" { + param = "--meta-token" + value = strings.TrimSpace(rctx.Str("meta-token")) + } + if value == "" { + return nil + } + if err := charcheck.RejectControlChars(value, param); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err). + WithParam(param).WithCause(err) + } + if strings.ContainsAny(value, "/ \t") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "%s must be a bare app id or share token, not a URL or a path", param). + WithParam(param). + WithHint(`from an app link .../app/ or a share link .../page/, pass only the last segment`) + } + return nil +} + +// validateExportCheckpointID keeps a non-numeric --checkpoint-id from reaching the +// gateway, where it would fail during i64 binding with a message that does not name +// the flag. Zero and negatives are rejected too: the server reads 0 as "latest", +// so passing it explicitly would silently ignore the flag the caller just set. +func validateExportCheckpointID(raw string) error { + value := strings.TrimSpace(raw) + if value == "" { + return nil + } + n, err := strconv.ParseInt(value, 10, 64) + if err != nil || n <= 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--checkpoint-id must be a positive integer, got %q", value). + WithParam("--checkpoint-id"). + WithHint("omit --checkpoint-id to export the latest commit on the default branch") + } + return nil +} + // requireExactlyOneExportSource enforces the app-id / meta-token XOR. // // Both empty or both set is a user error the server would also reject; failing diff --git a/shortcuts/apps/apps_export_test.go b/shortcuts/apps/apps_export_test.go index acfb276dca..61f8bb35a0 100644 --- a/shortcuts/apps/apps_export_test.go +++ b/shortcuts/apps/apps_export_test.go @@ -333,3 +333,90 @@ func TestAppsExport_ClassifiesFailures(t *testing.T) { }) } } + +// TestAppsExport_AcceptsTokenPassedAsAppID pins that the locator is NOT checked +// for the "app_" prefix. Server-side this endpoint accepts an app id or a meta +// token in the same path segment (same contract as +get, whose --app-id is +// documented as "app ID or meta token"), so rejecting a token here would make the +// CLI stricter than the API and diverge from +get. +func TestAppsExport_AcceptsTokenPassedAsAppID(t *testing.T) { + chdirTemp(t) + token := "DemoPageTokenAbCdEf123456" + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub(token, 200, []byte("ZIPDATA"), "application/octet-stream", "")) + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", token, "--output", "src.zip", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("Execute() = %v", err) + } +} + +// TestAppsExport_RejectsLinkAsLocator keeps a full URL from being percent-encoded +// into the locator segment, where the server answers "app not found for the given +// meta_token" — a 404 that reads as "wrong app" and sends the caller off verifying +// app ids instead of trimming the URL. Checked on whichever flag carried it. +func TestAppsExport_RejectsLinkAsLocator(t *testing.T) { + cases := []struct { + name string + flag string + value string + }{ + {"share url via meta-token", "--meta-token", "https://x.feishu.cn/page/DemoPageTokenAbCdEf1"}, + {"path fragment via meta-token", "--meta-token", "page/DemoPageTokenAbCdEf1"}, + {"inner space via meta-token", "--meta-token", "Demo Token"}, + {"app url via app-id", "--app-id", "https://x.feishu.cn/app/app_demo"}, + {"path fragment via app-id", "--app-id", "app/app_demo"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsExport, + []string{"+export", c.flag, c.value, "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != c.flag { + t.Fatalf("Param = %q, want %s", ve.Param, c.flag) + } + // The recovery must say "pass only the last segment"; "app not found" + // is exactly the wrong lesson for this input. + if !strings.Contains(ve.Hint, "last segment") { + t.Fatalf("Hint = %q, want it to point at the last segment", ve.Hint) + } + }) + } +} + +// TestAppsExport_RejectsInvalidCheckpointID keeps a non-numeric or non-positive +// checkpoint id from reaching the gateway, where i64 binding fails with a message +// that does not name the flag. Zero is rejected because the server reads it as +// "latest", silently ignoring the flag the caller just set. +func TestAppsExport_RejectsInvalidCheckpointID(t *testing.T) { + for _, value := range []string{"abc", "0", "-1", "1.5"} { + t.Run(value, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--checkpoint-id", value, "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--checkpoint-id" { + t.Fatalf("Param = %q, want --checkpoint-id", ve.Param) + } + }) + } +} + +// TestAppsExport_AcceptsValidCheckpointID guards the validator against being so +// strict it blocks the happy path. +func TestAppsExport_AcceptsValidCheckpointID(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte("ZIPDATA"), "application/octet-stream", "")) + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--checkpoint-id", "42", "--output", "src.zip", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("Execute() = %v", err) + } +} diff --git a/skills/lark-apps/references/lark-apps-export.md b/skills/lark-apps/references/lark-apps-export.md index 268e8fdbe8..6e4ba16c81 100644 --- a/skills/lark-apps/references/lark-apps-export.md +++ b/skills/lark-apps/references/lark-apps-export.md @@ -30,7 +30,11 @@ ## 命令骨架 - `--app-id` 与 `--meta-token` **恰传其一**:前者是自己的应用,后者是分享链接里的 token。 -- `--checkpoint-id` 可选,指定导出某个检查点;省略取默认分支最新提交。 + 服务端两者共用同一个 path 段、按 `app_` 前缀自行判别(与 `+get` 同契约),所以 flag 只是语义标注, + 不会因为"放错 flag"而失败。 + - 两者都只收**裸标识符**。拿到的是整条链接(`.../app/` 或 `.../page/`)时, + 只传最后一段——整条 URL 传进来会被本地拦下并提示,不会变成一个看起来像"应用不存在"的 404。 +- `--checkpoint-id` 可选,**正整数**,指定导出某个检查点;省略取默认分支最新提交(不要显式传 `0`)。 - `--output` 可选,相对当前目录;省略时用服务端给的文件名(通常是 `.zip`)。 ## 示例 @@ -57,4 +61,4 @@ lark-cli apps +export --app-id app_xxx --dry-run | 权限不足(403) | 你需要该应用的下载权限。**持有分享 token 不等于有权限** | | 应用不存在(404) | 用 `+list --keyword ` 核对 app_id | | 归档过大(413) | 超出导出体积上限,改用 `+git-credential-init` + 原生 git clone | -| 参数报错 | `--app-id` 与 `--meta-token` 只能给一个,且必须给一个 | +| 参数报错 | `--app-id` 与 `--meta-token` 只能给一个,且必须给一个;两者都要裸标识符(不是整条链接),`--checkpoint-id` 要正整数 |