mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
0493db0cd1
* 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).
394 lines
17 KiB
Go
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"
|
|
}
|