Files
木杉 0493db0cd1 feat(apps): add +export to download an app's source code as a zip (#2594)
* feat(apps): add +export to download an app's source code as a zip

Adds `lark-cli apps +export` to export a Miaoda app's source as a zip
archive via POST /open-apis/spark/v1/apps/export. Accepts exactly one
locator: --app-id or --meta-token (a creative app's share-link token),
plus optional --checkpoint-id and --output.

The client rejects non-archive responses instead of saving them: an
error envelope (JSON, or any non-archive content-type) is surfaced as an
error rather than written to the output file, using a content-type
allowlist. Includes the +export shortcut, its tests, and lark-apps skill
docs.

* fix(apps): correct +export not-published guidance to match gateway

The gateway reports an unpublished artifact-hosted app as HTTP 200 + JSON
{"code":40901,"msg":"app not published"}, which flows through the envelope
classifier rather than classifyExportErr's HTTP-422 branch. That code is not in
the shared spark table, so the caller got the raw message with no next step.

- annotate the live 40901 path with a publish-first hint and the
  failed_precondition subtype so its taxonomy matches the 422 sibling
- correct the defensive HTTP-422 branch: replace the stale 'code not in git /
  file storage' guidance with the publish-first semantics; share the hint text
  via a const so the two paths cannot drift
- update tests and the skill reference to the real not-published behavior; add a
  negative case pinning that non-40901 envelope codes get no publish hint
- drop an orphaned exportLookup comment left by the earlier path->body change

* fix(apps): drop +export --checkpoint-id until its value source exists

The flag took a checkpoint id but nothing in lark-cli surfaces a valid one:
there is no command to list an app's checkpoints, and the id is a server-side
DB row key. A caller (human or agent) has no way to know what to pass, so an
arbitrary value just yields a server invalid-argument. Removing the flag until
the feature is designed avoids exposing an uncallable knob.

- remove the --checkpoint-id flag, its validator, and the checkpoint_id body
  field; drop the now-unused strconv import
- remove the two checkpoint-specific tests and the flag's use in the dry-run test
- update the skill reference: drop the flag, its example, and the error-table
  mention; reword the commit-vs-sandbox note to not lean on the flag concept

Export still works by app_id or meta_token and always returns the latest commit
(git-form) or latest published build (artifact-hosted).
2026-09-11 14:50:38 +08:00

394 lines
17 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net/http"
"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/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: uncommitted sandbox changes are not included.",
"Example: lark-cli apps +export --app-id <app_id> --output ./src.zip",
"Example (share token): lark-cli apps +export --meta-token <token> # for an app shared with you; you still need download permission",
"Example (omit --output): lark-cli apps +export --app-id <app_id> # saves to ./<app_id>.zip",
},
Scopes: []string{exportScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id, e.g. app_xxx (exactly one of --app-id / --meta-token)"},
{Name: "meta-token", Desc: "creative app share token — the LAST path segment of a /page/<token> link, not the full URL (exactly one of --app-id / --meta-token)"},
{Name: "output", Desc: "local output path, must be relative to the current directory (default: <app_id>.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().
POST(exportPath()).
Desc("Download the app source archive and save it to --output").
Params(exportBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateExportFlags(rctx); err != nil {
return err
}
resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: exportPath(),
Body: exportBody(rctx),
})
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.
return validateExportLocatorShape(rctx)
}
// 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 <token> 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/<app_id> or a share link .../page/<token>, pass only the last segment`)
}
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
}
// exportPath is the app-source-export endpoint.
//
// It is a top-level action path (POST /apps/export), not /apps/:appID/...: the
// caller may hold only a meta_token (a shared creative app) and have no app_id to
// put in a path segment, so both locators travel in the request body instead. This
// also avoids the gateway swallowing a static /apps/<segment> under the registered
// GET /apps/:appID route.
func exportPath() string {
return apiBasePath + "/apps/export"
}
// exportBody builds the request body shared by DryRun and Execute so the dry-run
// output cannot drift from the real call. app_id / meta_token are exactly-one-of
// (validated upstream).
func exportBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" {
body["app_id"] = appID
}
if metaToken := strings.TrimSpace(rctx.Str("meta-token")); metaToken != "" {
body["meta_token"] = metaToken
}
return body
}
// 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 is an
// artifact-hosted type (static HTML / web app) that has no successfully published
// version yet, so there is nothing to archive; the hint tells the caller to
// publish first rather than to treat it as a wrong app id and retry.
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.SubtypeFailedPrecondition, "export failed: %s", detail).
WithHint("not a wrong app id and retrying will not help: %s", exportNotPublishedHint).
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 annotateExportEnvelopeErr(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))
}
// exportAppNotPublishedCode is the business code the gateway returns (as an
// HTTP 200 + JSON envelope, not a 4xx) when export is asked for an artifact-hosted
// app that has no successfully published build yet. It is not in the shared spark
// code table, so the classifier leaves it as an untyped API error with no
// actionable hint; annotateExportEnvelopeErr adds the export-specific one.
const exportAppNotPublishedCode = 40901
// exportNotPublishedHint is the recovery guidance for an app that has no published
// build to export yet. Shared by the live 200+JSON path (annotateExportEnvelopeErr)
// and the defensive HTTP-422 branch (classifyExportErr) so the two cannot drift.
const exportNotPublishedHint = "export serves the app's latest published build and this app has none yet; publish it first (the publish path depends on the app type), then re-run export"
// annotateExportEnvelopeErr adds export-scoped recovery guidance to the typed
// error the classifier produced from a 200+JSON error envelope.
//
// The gateway reports "app not published" as a business code the shared spark
// table does not carry, so the classifier can only pass the server's raw message
// through — leaving the caller with no next step, and typed only as the generic
// SubtypeUnknown. This is the failure a caller actually hits (verified on a live
// lane: HTTP 200 + {"code":40901,...}), so both the actionable hint and the
// precise subtype belong here, not on classifyExportErr's HTTP-422 branch, which
// the gateway does not currently exercise. Setting the subtype keeps the live
// path's taxonomy aligned with its 422 sibling so agents routing on subtype get
// the same signal. Command-scoped rather than added to the shared code table
// because "app not published" is meaningful for export specifically, and the
// classifier layer holds classification, not command recovery.
func annotateExportEnvelopeErr(err error) error {
problem, ok := errs.ProblemOf(err)
if !ok {
return err
}
if problem.Code == exportAppNotPublishedCode {
problem.Subtype = errs.SubtypeFailedPrecondition
problem.Hint = exportNotPublishedHint
}
return err
}
// 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"
}