diff --git a/shortcuts/apps/apps_export.go b/shortcuts/apps/apps_export.go new file mode 100644 index 0000000000..5ad5fa7430 --- /dev/null +++ b/shortcuts/apps/apps_export.go @@ -0,0 +1,394 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "errors" + "fmt" + "io" + "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" + "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 +// 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{exportScope}, + 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 := validateExportFlags(rctx); 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 := validateExportFlags(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() + + if err := rejectExportErrorEnvelope(rctx, resp); err != nil { + return err + } + + 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 + }, +} + +// 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 +// 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: + // 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"). + 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 + } +} + +// 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 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. +// +// 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 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) + } + // 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)) + } + 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 == "" { + 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 { + 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..61f8bb35a0 --- /dev/null +++ b/shortcuts/apps/apps_export_test.go @@ -0,0 +1,422 @@ +// 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_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_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) { + 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) + } + }) + } +} + +// 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/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..6e4ba16c81 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-export.md @@ -0,0 +1,64 @@ +# 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。 + 服务端两者共用同一个 path 段、按 `app_` 前缀自行判别(与 `+get` 同契约),所以 flag 只是语义标注, + 不会因为"放错 flag"而失败。 + - 两者都只收**裸标识符**。拿到的是整条链接(`.../app/` 或 `.../page/`)时, + 只传最后一段——整条 URL 传进来会被本地拦下并提示,不会变成一个看起来像"应用不存在"的 404。 +- `--checkpoint-id` 可选,**正整数**,指定导出某个检查点;省略取默认分支最新提交(不要显式传 `0`)。 +- `--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` 只能给一个,且必须给一个;两者都要裸标识符(不是整条链接),`--checkpoint-id` 要正整数 |