Compare commits

..

3 Commits

Author SHA1 Message Date
anguohui
4d3c709914 chore: add PPE headers and pin miaoda-cli alpha for testing 2026-07-13 17:00:39 +08:00
anngo-nk
1ab853023a feat(apps): support modern_html app type with TOS publish path and app type querying
* feat(apps): read LARKSUITE_CLI_AGENT env var and pass app_source in +create

* feat(apps): add queryAppMeta shared function for app_type/arch_type lookup

* feat(apps): skip scaffold for legacy html apps, pass app_type/arch_type for arch_type=4 html in +init

* feat(apps): add zip packaging for arch_type=4 html publish path

* feat(apps): add TOS upload path for arch_type=4 html in +html-publish with arch_type-based routing

* refactor(apps): replace appMeta struct with queryAppType string for simpler routing

* refactor(apps): simplify to source_agent in +create, unified scaffold in +init, revert html-publish changes

* feat(apps): add --source-path flag to +init for existing source file incorporation

* fix(apps): align queryAppType with actual API path and response structure

* refactor(apps): use appInfo struct to parse GET /apps/{id} response

* test(apps): add full_stack scaffold test case

* feat(apps): surface sync field in +release-create response

* refactor(apps): remove --template flag from +init, derive template from queryAppType with full_stack fallback

* style(apps): fix gofmt formatting in apps_init.go

* test(apps): improve coverage for sync field, queryAppType, and scaffoldInitArgs

* chore(apps): pin miaoda-cli to alpha version 0.1.20-alpha.dd573f8

* feat(apps): add modern_html enum and pass --app-type instead of --template to miaoda-cli

* chore: add global PPE headers for testing (x-use-ppe, x-tt-env)

* feat(apps): add TOS upload path in +html-publish for modern_html, add --tos-path to +release-create

* feat(apps): unify html-publish output structure with app_id for both html and modern_html

* fix(apps): use newFileTransferClient for TOS presigned upload to satisfy forbidigo lint

* test(apps): add coverage for runHTMLPublishTOS success, errors, and upload failures

* fix(apps): change pre_release API method from POST to GET

* fix(apps): adapt pre_release response from map to list<KV> format

* fix(apps): use PUT method and Content-Length for TOS presigned upload

* fix(apps): use tos_path instead of tosPath in release-create request body

* chore(apps): add npmmirror registry for npx miaoda-cli, fix TOS upload test to expect PUT

* refactor(apps): use envvars.AgentName() for source_agent in +create

* feat(apps): integrate release-create into html-publish for modern_html, auto-detect modern_html from doubao agent env

* refactor(apps): remove --tos-path flag from +release-create (now internal to html-publish)

* refactor(apps): remove app_id from html-publish output, update skill doc

* docs(apps): update html-publish description to reflect dual return values

* refactor(apps): remove doubao app_type conversion in +create, let server decide via source_agent

* feat(apps): skip env-pull for modern_html apps in +init

* test(apps): add tests for modern_html env-pull skip in +init

* refactor(apps): introduce appTypePolicy for init control points (skipInstall, skipEnvPull, skipSkillsSync)

* feat(apps): add init step timing and default git config for +init

* feat(apps): add +get shortcut to fetch single app detail by app_id

* chore(apps): remove init step timing (not ready for production)

* test(apps): add coverage for +get shortcut

* chore: remove PPE headers and revert miaoda-cli to @latest for production

* refactor(apps): extract shared prepareHTMLPublishTarball, fix stale comments, simplify queryAppType

* fix(apps): update html-publish dry-run desc, remove hardcoded API path

* fix(apps): use rctx.IO().ErrOut instead of os.Stderr, remove unused appInfo struct

* fix(apps): restore dry-run API path output for E2E compatibility

* refactor(apps): move --source-path control char validation to Validate for dry-run coverage

* fix(apps): update stale --template comments to --app-type in init tests

* test(apps): explicitly unset agent env var for test isolation
2026-07-13 16:43:07 +08:00
HanShaoshuai-k
e96c4fa581 test: isolate semantic waiver fixtures from wall clock 2026-07-13 14:05:47 +08:00
31 changed files with 1619 additions and 554 deletions

View File

@@ -9,12 +9,15 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/semantic"
)
func TestRunLoadsPolicyAndWaivers(t *testing.T) {
freezeNow(t, time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC))
repo := t.TempDir()
writeSemanticConfig(t, repo, `{
"schema_version": 1,
@@ -65,6 +68,8 @@ func TestRunLoadsPolicyAndWaivers(t *testing.T) {
}
func TestRunLoadsWaiversFromOverrideFile(t *testing.T) {
freezeNow(t, time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC))
repo := t.TempDir()
writeSemanticConfig(t, repo, `{
"schema_version": 1,
@@ -370,6 +375,13 @@ func writeSemanticConfig(t *testing.T, repo, policy, models, waivers string) {
}
}
func freezeNow(t *testing.T, fixed time.Time) {
t.Helper()
original := now
now = func() time.Time { return fixed }
t.Cleanup(func() { now = original })
}
func readDecision(t *testing.T, path string) semantic.Decision {
t.Helper()
data, err := os.ReadFile(path)

View File

@@ -9,6 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -61,6 +62,7 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
// --app-type is constrained to the lowercase enum (html / full_stack) by the
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
// a server concern and is intentionally not surfaced by the CLI.
agent := envvars.AgentName()
body := map[string]interface{}{
"name": strings.TrimSpace(rctx.Str("name")),
"app_type": rctx.Str("app-type"),
@@ -71,5 +73,8 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
body["icon_url"] = icon
}
if agent != "" {
body["source_agent"] = agent
}
return body
}

View File

@@ -273,3 +273,93 @@ func TestAppsCreate_FullstackDryRun(t *testing.T) {
t.Fatalf("dry-run should not contain message: %s", got)
}
}
func TestAppsCreate_WithAgentEnvVar(t *testing.T) {
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "doubao")
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["source_agent"] != "doubao" {
t.Fatalf("body.source_agent = %v, want doubao", sent["source_agent"])
}
}
func TestAppsCreate_WithoutAgentEnvVar(t *testing.T) {
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "")
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if _, present := sent["source_agent"]; present {
t.Fatalf("source_agent should not be present when env var is empty: %v", sent)
}
}
func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "")
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if _, present := sent["source_agent"]; present {
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
}
}

View File

@@ -44,8 +44,9 @@ func appsExternalToolError(err error, format string, args ...any) *errs.Internal
return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err)
}
// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a
// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response.
// appsSubprocessEnvelopeError classifies a malformed or unexpected response
// structure as internal/invalid_response. Used for subprocess envelopes
// (+git-credential-init / +env-pull) and server responses (e.g. pre_release).
func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsGet fetches a single app's detail by app ID.
var AppsGet = common.Shortcut{
Service: appsService,
Command: "+get",
Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +get --app-id <app_id>",
"Example: lark-cli apps +get --app-id <app_id> --dry-run",
"Tip: extract app type with --jq '.data.app.app_type'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Get app detail (returns app_id, app_type, name, description, icon_url, created_at, updated_at, is_published)")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
data, err := rctx.CallAPITyped("GET", fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID)), nil, nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
app, _ := data["app"].(map[string]interface{})
if app == nil {
return
}
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
fmt.Fprintf(w, "app_type: %v\n", app["app_type"])
fmt.Fprintf(w, "name: %v\n", app["name"])
if desc, ok := app["description"].(string); ok && desc != "" {
fmt.Fprintf(w, "description: %s\n", desc)
}
fmt.Fprintf(w, "is_published: %v\n", app["is_published"])
fmt.Fprintf(w, "updated_at: %v\n", app["updated_at"])
})
return nil
},
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsGet_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_test",
"app_type": "html",
"name": "TestApp",
"description": "A test application",
"icon_url": "https://example.com/icon.svg",
"is_published": true,
"created_at": "2026-05-18T10:00:00Z",
"updated_at": "2026-06-01T12:00:00Z",
},
},
},
})
if err := runAppsShortcut(t, AppsGet,
[]string{"+get", "--app-id", "app_test", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "app_test") {
t.Fatalf("stdout missing app_id: %s", got)
}
if !strings.Contains(got, "html") {
t.Fatalf("stdout missing app_type: %s", got)
}
}
func TestAppsGet_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsGet,
[]string{"+get", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected app-id required error, got %v", err)
}
}
func TestAppsGet_EmptyAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsGet,
[]string{"+get", "--app-id", "", "--as", "user"}, factory, stdout)
requireAppsValidationProblem(t, err)
}
func TestAppsGet_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsGet,
[]string{"+get", "--app-id", "app_test", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_test") {
t.Fatalf("dry-run missing API path: %s", got)
}
}
func TestAppsGet_PrettyOutput(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_test",
"app_type": "html",
"name": "PrettyApp",
"description": "A pretty test app",
"is_published": true,
"updated_at": "2026-06-01T12:00:00Z",
},
},
},
})
if err := runAppsShortcut(t, AppsGet,
[]string{"+get", "--app-id", "app_test", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"app_id:", "app_type:", "name:", "is_published:"} {
if !strings.Contains(got, want) {
t.Fatalf("pretty output missing %q: %s", want, got)
}
}
}

View File

@@ -4,29 +4,32 @@
package apps
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsHTMLPublish packs --path as tar.gz and uploads + publishes via one multipart POST.
// AppsHTMLPublish packs --path as tar.gz and publishes an HTML app.
var AppsHTMLPublish = common.Shortcut{
Service: appsService,
Command: "+html-publish",
Description: "Publish HTML to an app (single multipart POST returns the access URL)",
Description: "Publish HTML to an app (returns url or release_id depending on app type)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./dist",
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./site --dry-run",
},
Scopes: []string{"spark:app:write"},
Scopes: []string{"spark:app:write", "spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
@@ -70,7 +73,7 @@ var AppsHTMLPublish = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
path := strings.TrimSpace(rctx.Str("path"))
dry := common.NewDryRunAPI()
dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)")
dry.Desc("Pack tar.gz and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
Set("content_type", "multipart/form-data")
@@ -119,8 +122,17 @@ var AppsHTMLPublish = common.Shortcut{
AppID: strings.TrimSpace(rctx.Str("app-id")),
Path: strings.TrimSpace(rctx.Str("path")),
}
client := appsHTMLPublishAPI{runtime: rctx}
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
appType := queryAppType(ctx, rctx, spec.AppID)
var out map[string]interface{}
var err error
if appType == "modern_html" {
out, err = runHTMLPublishTOS(ctx, rctx, spec)
} else {
client := appsHTMLPublishAPI{runtime: rctx}
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
}
if err != nil {
return err
}
@@ -128,6 +140,9 @@ var AppsHTMLPublish = common.Shortcut{
if url, ok := out["url"].(string); ok && url != "" {
fmt.Fprintf(w, "url: %s\n", url)
}
if rid, ok := out["release_id"].(string); ok && rid != "" {
fmt.Fprintf(w, "release_id: %s\n", rid)
}
})
return nil
},
@@ -214,8 +229,11 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
WithHint("index.html is the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
}
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
// prepareHTMLPublishTarball validates candidates under path and builds a
// tar.gz payload ready for upload. Shared by runHTMLPublish and
// runHTMLPublishTOS.
func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarball, error) {
candidates, err := walkHTMLPublishCandidates(fio, path)
if err != nil {
return nil, err
}
@@ -238,12 +256,19 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
if err != nil {
return nil, err
}
if tarball.Size > maxHTMLPublishTarballBytes {
return nil, appsValidationParamError("--path",
"packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes).
WithHint("reduce --path contents, remove unrelated large files, then retry")
}
return tarball, nil
}
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
tarball, err := prepareHTMLPublishTarball(fio, spec.Path)
if err != nil {
return nil, err
}
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
if err != nil {
@@ -256,3 +281,74 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
}
return out, nil
}
// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
// tos_path for +release-create --tos-path.
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
tarball, err := prepareHTMLPublishTarball(rctx.FileIO(), spec.Path)
if err != nil {
return nil, err
}
// Step 1: call pre_release to get TOS upload URL and tos_path.
preReleasePath := fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(spec.AppID))
preData, err := rctx.CallAPITyped("GET", preReleasePath, nil, nil)
if err != nil {
return nil, err
}
kvs, _ := preData["kvs"].([]interface{})
if len(kvs) == 0 {
return nil, appsSubprocessEnvelopeError("pre_release returned no kvs")
}
kvm := make(map[string]string, len(kvs))
for _, item := range kvs {
kv, _ := item.(map[string]interface{})
if kv == nil {
continue
}
k, _ := kv["key"].(string)
v, _ := kv["value"].(string)
if k != "" {
kvm[k] = v
}
}
uploadURL := kvm["upload_url"]
tosPath := kvm["tos_path"]
if uploadURL == "" || tosPath == "" {
return nil, appsSubprocessEnvelopeError("pre_release kvs missing upload_url or tos_path")
}
// Step 2: upload tar.gz to TOS via presigned URL (bypasses Lark gateway).
//nolint:forbidigo // presigned TOS upload bypasses the Lark gateway — raw http is required; not a Lark API call, so RuntimeContext.DoAPI does not apply.
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(tarball.Body))
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "build TOS upload request").WithCause(err)
}
req.ContentLength = tarball.Size
req.Header.Set("Content-Type", "application/gzip")
resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // presigned TOS upload, see above.
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed").WithCause(err).WithRetryable()
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "TOS upload failed: HTTP %d", resp.StatusCode).WithRetryable()
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed: HTTP %d", resp.StatusCode)
}
// Step 3: call release-create with tos_path to trigger deployment.
releasePath := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(spec.AppID))
releaseData, err := rctx.CallAPITyped("POST", releasePath, nil, map[string]interface{}{
"tos_path": tosPath,
})
if err != nil {
return nil, err
}
return map[string]interface{}{
"release_id": common.GetString(releaseData, "release_id"),
}, nil
}

View File

@@ -6,10 +6,21 @@ package apps
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
type fakeAppsHTMLPublishClient struct {
@@ -53,7 +64,7 @@ func TestRunHTMLPublish_HappyPath(t *testing.T) {
}
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步":
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
// envelope 只含 url未来若有人加 status / release_id 字段会被这个测试拦截。
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
@@ -582,3 +593,226 @@ func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
t.Fatalf("client should be called; calls=%v", fake.calls)
}
}
// ── runHTMLPublishTOS tests ──
// permissiveFIOProvider wraps permissiveFIO as a fileio.Provider for tests
// that call runHTMLPublishTOS (which obtains FileIO via rctx.FileIO()).
type permissiveFIOProvider struct{}
func (permissiveFIOProvider) Name() string { return "test-permissive" }
func (permissiveFIOProvider) ResolveFileIO(context.Context) fileio.FileIO { return permissiveFIO{} }
// newTOSTestRuntime builds a RuntimeContext with httpmock registry and a
// permissive FileIO provider, ready for runHTMLPublishTOS unit tests.
func newTOSTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
factory.FileIOProvider = permissiveFIOProvider{}
rt := common.TestNewRuntimeContextForAPI(
context.Background(),
&cobra.Command{Use: "+tos-test"},
cfg, factory, core.AsUser,
)
return rt, reg
}
func TestRunHTMLPublishTOS_Success(t *testing.T) {
site := writeAppsSampleSite(t)
rt, reg := newTOSTestRuntime(t)
// Start httptest server to accept the TOS upload.
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("TOS upload method = %s, want PUT", r.Method)
}
if ct := r.Header.Get("Content-Type"); ct != "application/gzip" {
t.Errorf("TOS upload Content-Type = %s, want application/gzip", ct)
}
w.WriteHeader(http.StatusOK)
}))
defer tosServer.Close()
// Register pre_release API stub.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"kvs": []interface{}{
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
},
},
},
})
// Register release-create API stub.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_tos/releases",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"release_id": "rel_123",
"status": "publishing",
},
},
})
out, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: site,
})
if err != nil {
t.Fatalf("err=%v", err)
}
if out["release_id"] != "rel_123" {
t.Fatalf("release_id=%v, want rel_123", out["release_id"])
}
}
func TestRunHTMLPublishTOS_MissingIndexHTML(t *testing.T) {
dir := t.TempDir()
// Create a file that is NOT named index.html.
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
rt, _ := newTOSTestRuntime(t)
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: dir,
})
if err == nil {
t.Fatalf("expected error for missing index.html")
}
if !strings.Contains(err.Error(), "index.html") {
t.Fatalf("error should mention index.html, got: %v", err)
}
}
func TestRunHTMLPublishTOS_PreReleaseError(t *testing.T) {
site := writeAppsSampleSite(t)
rt, reg := newTOSTestRuntime(t)
// Register pre_release API stub that returns an error code.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
Body: map[string]interface{}{
"code": float64(99999),
"msg": "internal server error",
},
})
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: site,
})
if err == nil {
t.Fatalf("expected error from pre_release API failure")
}
}
func TestRunHTMLPublishTOS_MissingParams(t *testing.T) {
site := writeAppsSampleSite(t)
rt, reg := newTOSTestRuntime(t)
// Register pre_release API stub that returns empty kvs list.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"kvs": []interface{}{},
},
},
})
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: site,
})
if err == nil {
t.Fatalf("expected error for empty kvs")
}
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if !strings.Contains(problem.Message, "no kvs") {
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
}
}
func TestRunHTMLPublishTOS_MissingParamsObject(t *testing.T) {
site := writeAppsSampleSite(t)
rt, reg := newTOSTestRuntime(t)
// Register pre_release API stub that returns no kvs key at all.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{},
},
})
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: site,
})
if err == nil {
t.Fatalf("expected error for missing kvs")
}
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if !strings.Contains(problem.Message, "no kvs") {
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
}
}
func TestRunHTMLPublishTOS_UploadFails(t *testing.T) {
site := writeAppsSampleSite(t)
rt, reg := newTOSTestRuntime(t)
// Start httptest server that returns 500.
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer tosServer.Close()
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"kvs": []interface{}{
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
},
},
},
})
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: site,
})
if err == nil {
t.Fatalf("expected error from TOS upload failure")
}
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
if !strings.Contains(problem.Message, "500") {
t.Fatalf("error should mention HTTP 500, got: %q", problem.Message)
}
}

View File

@@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"unicode"
@@ -38,17 +39,58 @@ const (
)
const (
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
defaultTemplate = "nestjs-react-fullstack"
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.20-alpha.dd573f8"
npmRegistry = "https://registry.npmmirror.com"
metaRelPath = ".spark/meta.json"
steeringRelPath = ".agent/skills/steering"
seedReadme = "README.md"
)
// Fallback committer identity written to the cloned repo's LOCAL git config when
// no user.name/user.email is resolvable (from local, global, or system config).
// The scaffold's `git commit` would otherwise fail with "please tell me who you
// are"; an existing identity (e.g. the developer's global config) is respected.
const (
defaultGitUserName = "lark-cli-bot"
defaultGitUserEmail = "lark-cli-bot@miaoda.com"
)
// initRunner is the commandRunner used by +init. Package-level so unit tests
// can swap in a fakeCommandRunner. Production uses execCommandRunner.
var initRunner commandRunner = execCommandRunner{}
// appTypePolicy captures the per-app-type control points +init toggles, keeping
// each knob out of the inline `appType == "..."` checks that would otherwise be
// scattered through the flow. Add a field here (and set it in appTypePolicies)
// for each new control point rather than threading another type comparison
// through appsInitExecute.
type appTypePolicy struct {
// skipInstall passes --skip-install to `npx ... app init`, so scaffolding
// runs no dependency install.
skipInstall bool
// skipEnvPull skips the post-init `+env-pull` step, on both the fresh-init
// tail and the already-initialized refresh path.
skipEnvPull bool
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
// the non-empty (`app sync`) scaffold path.
skipSkillsSync bool
}
// appTypePolicies maps an app_type to its +init control strategy. Types absent
// from the map get the zero-value policy (install runs, env is pulled, skills
// are synced).
var appTypePolicies = map[string]appTypePolicy{
// modern_html is a static HTML site: no dependencies to install, no startup
// env vars to pull, and no steering skills to sync.
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true},
}
// policyForAppType returns the +init control strategy for appType. Unlisted
// types (including "") get the zero-value policy.
func policyForAppType(appType string) appTypePolicy {
return appTypePolicies[appType]
}
// AppsInit initializes an app's code and local development environment.
var AppsInit = common.Shortcut{
Service: appsService,
@@ -59,13 +101,15 @@ var AppsInit = common.Shortcut{
"Example: lark-cli apps +init --app-id <app_id> --dir <dir>",
"Example: lark-cli apps +init --app-id <app_id> --dir <dir> --dry-run",
},
// +init makes no direct lark API calls (it shells out to the
// +git-credential-init subprocess, which enforces its own scopes), so it
// declares no scopes of its own. Explicit []string{} (not nil) per the
// convention enforced by TestAllShortcutsScopesNotNil.
Scopes: []string{},
AuthTypes: []string{"user"},
HasFormat: true,
// +init calls queryAppType (GET /apps/{id}) which requires spark:app:read;
// the scope is declared as conditional since the call is non-fatal.
// The git credential subprocess enforces its own scopes independently.
// Explicit []string{} (not nil) per the convention enforced by
// TestAllShortcutsScopesNotNil.
Scopes: []string{},
ConditionalScopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
// NOTE: --app-id is intentionally NOT Required:true. The framework maps
// Required:true to cobra's MarkFlagRequired, whose error is plain-text
@@ -75,24 +119,28 @@ var AppsInit = common.Shortcut{
// check lives in Validate (typed validation error -> exit 2).
{Name: "app-id", Desc: "app ID"},
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if sp := strings.TrimSpace(rctx.Str("source-path")); sp != "" {
if err := charcheck.RejectControlChars(sp, "--source-path"); err != nil {
return appsValidationParamError("--source-path", "%v", err).WithCause(err)
}
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
template := resolveTemplate(rctx, appID)
dry := common.NewDryRunAPI().
Desc("Initialize app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
Set("checkout", "git checkout "+defaultInitBranch).
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --app-type <appType> --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, appID, miaodaCLIPkg)).
Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
Set("template", template).
Set("template", "derived from queryAppType (fallback: full_stack)").
Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
dir, err := resolveTargetPath(rctx, appID)
if err != nil {
@@ -122,20 +170,6 @@ func defaultCloneDir(appID string) string {
return filepath.Join(".", appID)
}
// resolveTemplate returns the scaffold template for an empty-repo `app init`.
// An explicit --template wins. When omitted, it should be derived from the
// app's tech stack.
// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list`
// or a get-app endpoint), read its tech stack, and map tech-stack -> template
// through a (future) enum. Until that lands, fall back to defaultTemplate.
func resolveTemplate(rctx *common.RuntimeContext, appID string) string {
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
return t
}
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
return defaultTemplate
}
// initLogf writes a one-line progress message to stderr. stdout stays reserved
// for the structured JSON envelope, so progress never pollutes it. Callers must
// never pass a raw repository_url (it may embed a token) — pass step names,
@@ -294,6 +328,34 @@ func ensureMetaAppID(dir, appID string) error {
return nil
}
// ensureGitIdentity guarantees the cloned repo has a committer identity so the
// scaffold's `git commit` cannot fail with "please tell me who you are". It sets
// the repo-LOCAL user.name/user.email to the lark-cli-bot defaults ONLY when
// each is not already resolvable from local/global/system config, so a
// developer's existing identity is never overwritten. Each key is handled
// independently (a machine with only user.name set still gets a default email).
func ensureGitIdentity(ctx context.Context, dir string) error {
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
return err
}
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
}
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
// resolves to no value. `git config --get` exits non-zero (or prints nothing)
// when the key is unset at every scope; any resolved value (including one
// inherited from global/system) is left untouched.
func ensureGitConfigValue(ctx context.Context, dir, key, fallback string) error {
stdout, _, err := initRunner.Run(ctx, dir, "git", "config", "--get", key)
if err == nil && strings.TrimSpace(stdout) != "" {
return nil // already configured at some scope — respect it
}
if _, stderr, e := initRunner.Run(ctx, dir, "git", "config", key, fallback); e != nil {
return appsExternalToolError(e, "git config %s failed: %s", key, gitErr(stderr, e))
}
return nil
}
// hasSteeringSkills reports whether <dir>/.agent/skills/steering exists as a dir.
func hasSteeringSkills(dir string) bool {
info, err := os.Stat(filepath.Join(dir, steeringRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
@@ -326,34 +388,54 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir).
// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch +
// conditional `skills sync`. Returns "init" or "upgrade".
func runScaffold(ctx context.Context, dir, appID, template string) (string, error) {
func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) {
empty, err := isEmptyRepo(ctx, dir)
if err != nil {
return "", err
}
if empty {
// isEmptyRepo treats a repo with no tracked files — or only the backend's
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
// appear, extend isEmptyRepo's allow-list accordingly.
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
args := scaffoldInitArgs(appType, appID, sourcePath)
if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); err != nil {
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
}
return scaffoldKindInit, nil
}
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
}
if err := ensureMetaAppID(dir, appID); err != nil {
return "", err
}
if !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
}
}
return scaffoldKindUpgrade, nil
}
// scaffoldInitArgs builds the npx argument list for `app init`.
// appType from queryAppType is passed as --app-type; falls back to "full_stack"
// when empty. sourcePath is appended as --source-path when non-empty.
// --skip-install is appended per the app_type's policy (see appTypePolicy):
// types whose policy sets skipInstall (e.g. modern_html) skip the dependency
// install; others run it as usual.
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"}
at := appType
if at == "" {
at = "full_stack"
}
base = append(base, "--app-type", at, "--app-id", appID)
if sourcePath != "" {
base = append(base, "--source-path", sourcePath)
}
if policyForAppType(appType).skipInstall {
base = append(base, "--skip-install")
}
return base
}
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
// matches the contract emitted by `apps +git-credential-init`.
@@ -445,6 +527,9 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
appType := queryAppType(ctx, rctx, appID)
policy := policyForAppType(appType)
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.
@@ -457,6 +542,19 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
"committed": false,
"pushed": false,
}
if appType != "" {
out["app_type"] = appType
}
if policy.skipEnvPull {
out["env_pulled"] = false
out["env_pull_skipped"] = true
out["message"] = "Repository already initialized. You can start developing."
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Already initialized at %s\n", dir)
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
})
return nil
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
@@ -514,8 +612,21 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
}
// Ensure a committer identity exists before the scaffold commit; only sets
// repo-local defaults when none is configured (existing identity is kept).
if err := ensureGitIdentity(ctx, dir); err != nil {
return err
}
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
sourcePath := strings.TrimSpace(rctx.Str("source-path"))
if sourcePath != "" {
sourcePath, err = filepath.Abs(sourcePath) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); sourcePath is control-char-validated in Validate.
if err != nil {
return appsValidationParamError("--source-path", "--source-path cannot be resolved: %v", err)
}
}
scaffold, err := runScaffold(ctx, dir, appID, appType, sourcePath)
if err != nil {
return err
}
@@ -530,15 +641,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
initLogf(rctx, "Working tree clean — skipped commit/push")
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
if envPulled {
initLogf(rctx, "Local environment written to %s", envFile)
} else {
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
}
out := map[string]interface{}{
"app_id": appID,
"repository_url": redactURLCredentials(repoURL),
@@ -547,21 +649,38 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
"scaffold": scaffold,
"committed": committed,
"pushed": pushed,
"env_pulled": envPulled,
"message": "Repository initialized. You can start developing.",
}
if envPulled {
out["env_file"] = envFile
} else {
out["env_pull_error"] = envPullErr
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
if appType != "" {
out["app_type"] = appType
}
if policy.skipEnvPull {
out["env_pulled"] = false
out["env_pull_skipped"] = true
} else {
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
out["env_pulled"] = envPulled
if envPulled {
initLogf(rctx, "Local environment written to %s", envFile)
out["env_file"] = envFile
} else {
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
out["env_pull_error"] = envPullErr
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
}
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)
if envPulled {
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
} else {
if policy.skipEnvPull {
fmt.Fprintln(w, " (env pull skipped)")
} else if envPulled, _ := out["env_pulled"].(bool); envPulled {
fmt.Fprintf(w, "✓ Local environment written to %s\n", out["env_file"])
} else if envPullErr, ok := out["env_pull_error"].(string); ok {
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
}

View File

@@ -20,46 +20,20 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// testRuntimeWithDir builds a *common.RuntimeContext whose backing cobra command
// has string flags "dir" (=dirFlag) and "template" (=defaultTemplate) registered,
// mirroring how +init reads them at runtime via rctx.Str.
// has a string flag "dir" (=dirFlag) registered, mirroring how +init reads it
// at runtime via rctx.Str.
func testRuntimeWithDir(t *testing.T, dirFlag string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "init"}
cmd.Flags().String("dir", dirFlag, "")
cmd.Flags().String("template", defaultTemplate, "")
return common.TestNewRuntimeContext(cmd, nil)
}
// testRuntimeWithTemplate builds a *common.RuntimeContext with "dir" and
// "template" string flags registered, mirroring +init's runtime flag set. The
// template flag is registered with an empty default (matching the real flag,
// which no longer carries Default: defaultTemplate); pass tpl="" to model an
// omitted --template and a non-empty tpl to model an explicit one.
func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "init"}
cmd.Flags().String("dir", dirFlag, "")
cmd.Flags().String("template", tpl, "")
return common.TestNewRuntimeContext(cmd, nil)
}
func TestResolveTemplate(t *testing.T) {
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" {
t.Errorf("explicit --template = %q, want foo", got)
}
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); got != defaultTemplate {
t.Errorf("omitted --template = %q, want fallback %q", got, defaultTemplate)
}
// Whitespace-only --template is treated as omitted -> fallback.
if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), "app_x"); got != defaultTemplate {
t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate)
}
}
func TestResolveTargetPath(t *testing.T) {
got, err := resolveTargetPath(testRuntimeWithDir(t, ""), "app_x")
if err != nil {
@@ -261,12 +235,12 @@ func TestRunScaffold_EmptyRepo(t *testing.T) {
t.Run("ls="+ls, func(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ls}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
if err != nil || kind != "init" {
t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err)
}
c := findCall(f.calls, "npx", "-y")
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", "nestjs-react-fullstack", "--app-id", "app_x") {
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("app init not invoked with expected args: %v", f.calls)
}
if c != nil && containsAll(c, "--local") {
@@ -280,7 +254,7 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
dir := t.TempDir() // no steering dir, no meta.json
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack")
kind, err := runScaffold(context.Background(), dir, "app_x", "", "")
if err != nil || kind != "upgrade" {
t.Fatalf("kind=%q err=%v, want upgrade", kind, err)
}
@@ -294,12 +268,24 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
}
}
func TestRunScaffold_NonEmpty_ModernHTML_SkipsSyncEvenWithoutSteering(t *testing.T) {
dir := t.TempDir() // no steering dir → sync would run for non-modern_html
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
withFakeRunner(t, f)
if _, err := runScaffold(context.Background(), dir, "app_x", "modern_html", ""); err != nil {
t.Fatal(err)
}
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
t.Error("skills sync must be skipped for modern_html regardless of steering dir")
}
}
func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755)
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
withFakeRunner(t, f)
if _, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack"); err != nil {
if _, err := runScaffold(context.Background(), dir, "app_x", "", ""); err != nil {
t.Fatal(err)
}
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
@@ -313,7 +299,7 @@ func TestRunScaffold_AppInitFailure(t *testing.T) {
"npx -y": {stderr: "boom", err: errors.New("exit 1")},
}}
withFakeRunner(t, f)
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack"); err == nil {
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", ""); err == nil {
t.Error("app init failure must propagate")
}
}
@@ -342,13 +328,13 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
if _, ok := data["npx_skipped"]; ok {
t.Error("npx_skipped must be removed")
}
// --template is omitted here, so resolveTemplate falls back to
// defaultTemplate and `app init` must still receive --template nestjs-react-fullstack.
// appType is empty, so scaffoldInitArgs falls back to "full_stack"
// and `app init` must still receive --app-type full_stack.
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Error("npx scaffold not invoked")
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", defaultTemplate, "--app-id", "app_x") {
t.Errorf("app init missing expected --template fallback args: %v", c)
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("app init missing expected --app-type fallback args: %v", c)
} else if containsAll(c, "--local") {
t.Errorf("app init must NOT carry --local: %v", c)
}
@@ -751,22 +737,6 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
}
func TestAppsInit_Req1_Wording(t *testing.T) {
var tmpl *common.Flag
for i := range AppsInit.Flags {
if AppsInit.Flags[i].Name == "template" {
tmpl = &AppsInit.Flags[i]
}
}
if tmpl == nil {
t.Fatal("--template flag missing")
}
if strings.Contains(strings.ToLower(tmpl.Desc), "scaffold") {
t.Errorf("--template Desc still mentions scaffold: %q", tmpl.Desc)
}
if !strings.Contains(strings.ToLower(tmpl.Desc), "code-init") {
t.Errorf("--template Desc should use code-init wording: %q", tmpl.Desc)
}
// The --dry-run output is a flat object (DryRunAPI marshals to top-level keys
// description/scaffold/api/...), NOT wrapped in {"data":...}, so parse stdout
// directly rather than via parseEnvelopeData.
@@ -787,9 +757,8 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
t.Error("dry-run must keep machine-contract key `scaffold`")
} else if !strings.Contains(scaffold, "skills sync --local") {
t.Errorf("dry-run scaffold string must show --local on skills sync: %q", scaffold)
} else if strings.Contains(scaffold, "app init --template nestjs-react-fullstack --app-id app_x --local") ||
strings.Contains(scaffold, "app sync --local") {
t.Errorf("dry-run scaffold string must NOT show --local on app init / app sync: %q", scaffold)
} else if strings.Contains(scaffold, "app sync --local") {
t.Errorf("dry-run scaffold string must NOT show --local on app sync: %q", scaffold)
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{
@@ -1250,7 +1219,7 @@ func TestRunScaffold_NonEmpty_SyncFailure(t *testing.T) {
"git ls-files": {stdout: "src/x.ts\n"},
"npx -y": {err: errors.New("sync boom")},
}})
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "tpl"); err == nil {
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", ""); err == nil {
t.Error("npx app sync failure must surface as an error")
}
}
@@ -1630,7 +1599,7 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
}}
withFakeRunner(t, f)
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
if err == nil {
t.Fatalf("expected error from failing git subprocess")
}
@@ -1645,3 +1614,368 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
func TestRunScaffold_HtmlPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "html", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "html") {
t.Errorf("expected --app-type html in args: %v", c)
}
}
func TestRunScaffold_ModernHtmlPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "modern_html", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "modern_html") {
t.Errorf("expected --app-type modern_html in args: %v", c)
}
}
func TestRunScaffold_EmptyAppTypeFallback(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "full_stack") {
t.Errorf("expected --app-type full_stack in args: %v", c)
}
}
func TestRunScaffold_FullStackPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "full_stack", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "full_stack") {
t.Errorf("expected --app-type full_stack in args: %v", c)
}
}
func TestScaffoldInitArgs_WithAppType(t *testing.T) {
args := scaffoldInitArgs("modern_html", "app_x", "")
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x") {
t.Errorf("expected --app-type modern_html --app-id app_x, got %v", args)
}
// modern_html skips dependency install.
if !containsAll(args, "--skip-install") {
t.Errorf("expected --skip-install for modern_html, got %v", args)
}
for _, a := range args {
if a == "--source-path" {
t.Errorf("--source-path must not appear when sourcePath is empty: %v", args)
}
}
}
func TestPolicyForAppType(t *testing.T) {
// modern_html decouples all control points: skip install, env-pull, skills sync.
if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
t.Errorf("modern_html policy = %+v, want all skip flags set", p)
}
// Unlisted types (including "") get the zero-value policy: everything runs.
for _, at := range []string{"full_stack", "", "backend"} {
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
t.Errorf("policy for %q = %+v, want zero value", at, p)
}
}
}
func TestScaffoldInitArgs_SkipInstallOnlyForModernHTML(t *testing.T) {
// Non-modern_html types run the install step (no --skip-install).
for _, at := range []string{"full_stack", "", "backend"} {
args := scaffoldInitArgs(at, "app_x", "")
for _, a := range args {
if a == "--skip-install" {
t.Errorf("--skip-install must not appear for app-type %q: %v", at, args)
}
}
}
}
func TestScaffoldInitArgs_EmptyFallback(t *testing.T) {
args := scaffoldInitArgs("", "app_x", "")
if !containsAll(args, "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("expected --app-type full_stack fallback, got %v", args)
}
}
func TestScaffoldInitArgs_WithSourcePath(t *testing.T) {
args := scaffoldInitArgs("modern_html", "app_x", "/path/to/src")
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x", "--source-path", "/path/to/src") {
t.Errorf("expected --source-path /path/to/src, got %v", args)
}
}
// configSetValue finds a `git config <key> <value>` SET call (not a `--get`)
// in the recorded fake calls and returns its value.
func configSetValue(calls [][]string, key string) (string, bool) {
for _, c := range calls {
if len(c) >= 5 && c[1] == "git" && c[2] == "config" && c[3] == key {
return c[4], true
}
}
return "", false
}
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
t.Errorf("user.name set = (%q,%v), want %q", v, ok, defaultGitUserName)
}
if v, ok := configSetValue(f.calls, "user.email"); !ok || v != defaultGitUserEmail {
t.Errorf("user.email set = (%q,%v), want %q", v, ok, defaultGitUserEmail)
}
}
func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
// `git config --get` returns a value → identity resolvable, nothing is set.
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"git config": {stdout: "Existing Dev\n"},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := configSetValue(f.calls, "user.name"); ok {
t.Error("user.name must not be overwritten when already configured")
}
if _, ok := configSetValue(f.calls, "user.email"); ok {
t.Error("user.email must not be overwritten when already configured")
}
}
func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"git config": {stderr: "boom", err: errors.New("exit 1")},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
t.Error("expected error when git config set fails")
}
}
func TestAppsInit_WithAppType_FreshClone(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("http://u:t@h/app_typed.git"),
"git clone": {},
"git checkout": {},
"git ls-files": {stdout: ""},
"git status": {stdout: " A src/app.ts\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
// Register a meta mock so queryAppType returns "modern_html"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_typed",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_typed",
"app_type": "MODERN_HTML",
},
},
},
})
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["app_type"] != "modern_html" {
t.Errorf("app_type = %v, want modern_html", data["app_type"])
}
// Verify the scaffold used --app-type modern_html
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "modern_html") {
t.Errorf("expected --app-type modern_html, got %v", c)
}
}
func TestAppsInit_ModernHtml_SkipsEnvPull(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("https://git.test/app_mh.git"),
"git clone": {},
"git checkout": {},
"git ls-files": {stdout: ""},
"npx -y": {},
"git status": {stdout: ""},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_mh",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_mh",
"app_type": "MODERN_HTML",
},
},
},
})
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_mh", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["env_pull_skipped"] != true {
t.Errorf("env_pull_skipped = %v, want true", data["env_pull_skipped"])
}
if data["env_pulled"] != false {
t.Errorf("env_pulled = %v, want false", data["env_pulled"])
}
// Verify env-pull was NOT called
for _, c := range f.calls {
if len(c) >= 3 && c[2] == "apps" && len(c) >= 4 && c[3] == "+env-pull" {
t.Fatal("env-pull should not be called for modern_html")
}
}
}
func TestAppsInit_AlreadyInitialized_ModernHtml_SkipsEnvPull(t *testing.T) {
dir := relCloneDir(t)
abs, err := filepath.Abs(dir)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_mh2"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_mh2",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_mh2",
"app_type": "MODERN_HTML",
},
},
},
})
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_mh2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["scaffold"] != "already_initialized" {
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
}
if data["env_pull_skipped"] != true {
t.Errorf("env_pull_skipped = %v, want true", data["env_pull_skipped"])
}
if len(f.calls) != 0 {
t.Errorf("no commands should be called for already-initialized modern_html, got %v", f.calls)
}
}
func TestAppsInit_WithAppType_AlreadyInitialized(t *testing.T) {
dir := relCloneDir(t)
abs, err := filepath.Abs(dir)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_typed2"}`), 0o644); err != nil {
t.Fatal(err)
}
envFile := filepath.Join(abs, ".env.local")
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
// Register meta mock so queryAppType returns "html"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_typed2",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_typed2",
"app_type": "HTML",
},
},
},
})
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["scaffold"] != "already_initialized" {
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
}
if data["app_type"] != "html" {
t.Errorf("app_type = %v, want html", data["app_type"])
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// queryAppType fetches the app's type string from the server via
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
// to lowercase. Returns "" when the API is unavailable or returns an
// error — callers fall back to legacy behavior.
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("GET", path, nil, nil)
if err != nil {
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
return ""
}
appRaw, _ := data["app"].(map[string]interface{})
if appRaw == nil {
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
return ""
}
appType, _ := appRaw["app_type"].(string)
return strings.ToLower(appType)
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newMetaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_meta_test"}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := common.TestNewRuntimeContextForAPI(
context.Background(),
&cobra.Command{Use: "+meta-test"},
cfg, f, core.AsUser,
)
return rt, reg
}
func TestQueryAppType_Success(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_test",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_test",
"app_type": "MODERN_HTML",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_test")
if result != "modern_html" {
t.Errorf("queryAppType = %q, want modern_html", result)
}
}
func TestQueryAppType_FullStack(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_fs",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_fs",
"app_type": "FULL_STACK",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_fs")
if result != "full_stack" {
t.Errorf("queryAppType = %q, want full_stack", result)
}
}
func TestQueryAppType_Html(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_html",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_html",
"app_type": "HTML",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_html")
if result != "html" {
t.Errorf("queryAppType = %q, want html", result)
}
}
func TestQueryAppType_APIError(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_bad",
Status: 500,
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
})
result := queryAppType(context.Background(), rt, "app_bad")
if result != "" {
t.Errorf("queryAppType = %q, want empty on error", result)
}
}
func TestQueryAppType_MissingAppObject(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_no",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{},
},
})
result := queryAppType(context.Background(), rt, "app_no")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app object missing", result)
}
}
func TestQueryAppType_EmptyAppType(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_empty",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_empty",
"app_type": "",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_empty")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
}
}

View File

@@ -56,9 +56,10 @@ var AppsReleaseCreate = common.Shortcut{
out := map[string]interface{}{
"release_id": common.GetString(data, "release_id"),
"status": common.GetString(data, "status"),
"sync": common.GetBool(data, "sync"),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "release_id: %s\nstatus: %s\n", out["release_id"], out["status"])
fmt.Fprintf(w, "release_id: %s\nstatus: %s\nsync: %v\n", out["release_id"], out["status"], out["sync"])
})
return nil
},

View File

@@ -105,3 +105,45 @@ func TestAppsReleaseCreateExecute_Success(t *testing.T) {
t.Errorf("status = %v, want publishing", env.Data["status"])
}
}
func TestAppsReleaseCreate_SyncField(t *testing.T) {
rctx, stdoutBuf, reg := newReleaseCreateRuntimeContext(t, "app_sync", "main")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_sync/releases",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"release_id": "456",
"status": "publishing",
"sync": true,
},
},
})
err := AppsReleaseCreate.Execute(context.Background(), rctx)
if err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
}
if !env.OK {
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
}
if env.Data["release_id"] != "456" {
t.Errorf("release_id = %v, want 456", env.Data["release_id"])
}
if env.Data["status"] != "publishing" {
t.Errorf("status = %v, want publishing", env.Data["status"])
}
if env.Data["sync"] != true {
t.Errorf("sync = %v, want true", env.Data["sync"])
}
}

View File

@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
AppsCreate,
AppsGet,
AppsUpdate,
AppsList,
AppsAccessScopeSet,

View File

@@ -21,10 +21,10 @@ import (
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 plugininstall/uninstall/list= 63。
func TestAppsShortcuts_Returns63(t *testing.T) {
func TestAppsShortcuts_Returns64(t *testing.T) {
got := Shortcuts()
if len(got) != 63 {
t.Fatalf("Shortcuts() returned %d entries, want 63", len(got))
if len(got) != 64 {
t.Fatalf("Shortcuts() returned %d entries, want 64", len(got))
}
}

View File

@@ -431,6 +431,11 @@ func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]in
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
req.ExtraOpts = append(req.ExtraOpts, optFn)
}
// TODO: remove PPE headers once testing is complete and promoted to production.
ppeHeaders := http.Header{}
ppeHeaders.Set("x-use-ppe", "1")
ppeHeaders.Set("x-tt-env", "ppe_miaoda_lark_cli")
req.ExtraOpts = append(req.ExtraOpts, larkcore.WithHeaders(ppeHeaders))
return req
}

View File

@@ -212,10 +212,6 @@ func printMessageOutputSchema(runtime *common.RuntimeContext) {
// Used by --print-output-schema to let callers discover field names without reading skill docs.
func printWatchOutputSchema(runtime *common.RuntimeContext) {
schema := map[string]interface{}{
"_format_note": "Paths below are the bare per-event structure (as emitted by --format data). " +
"With the DEFAULT --format json, each event line is wrapped as " +
`{"ok":true,"identity":"user|bot","data":<structure below>} — prefix every path with .data ` +
"(e.g. .data.message.message_id; for --msg-format event, .data holds {header, event}).",
"minimal": map[string]interface{}{
"message": map[string]interface{}{
"message_id": "<message_id>",

View File

@@ -32,6 +32,24 @@ func TestMailWatchHelpListsJSONShorthand(t *testing.T) {
}
}
// 行为验证:--json 走 JSON 输出路径,不输出 table read hint
func TestMailTriageJSONShorthandDoesNotEmitReadHint(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1"}, f, stdout)
if err != nil {
t.Fatalf("triage --json returned error: %v", err)
}
reg.Verify(t)
if strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("--json must follow the JSON path, got table hint\nstderr=%s", stderr.String())
}
if !strings.Contains(stdout.String(), `"messages"`) {
t.Fatalf("--json stdout missing JSON payload\n%s", stdout.String())
}
}
// 等价性验证:--json 与 --format json 的 dry-run 输出一致
func TestMailTriageJSONShorthandDryRunEquivalence(t *testing.T) {
f1, stdout1, _, _ := mailShortcutTestFactory(t)
@@ -88,36 +106,7 @@ func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
t.Fatalf("message = %q, want enum validation message", problem.Message)
}
if !strings.Contains(problem.Message, "json, pretty, table, ndjson, csv") {
if !strings.Contains(problem.Message, "table, json, data") {
t.Fatalf("message = %q, want allowed values list", problem.Message)
}
}
// 回归:`data` 曾是合法取值envelope 化后从 Enum 移除,应被硬拒(而非静默降级)
func TestMailTriageRejectsRemovedDataFormat(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "data", "--max", "1", "--dry-run"}, f, stdout)
if err == nil {
t.Fatal("expected validation error for removed --format data")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T, want typed errs problem carrier", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if ve.Param != "--format" {
t.Fatalf("param = %q, want --format", ve.Param)
}
if !strings.Contains(problem.Message, `invalid value "data" for --format`) {
t.Fatalf("message = %q, want data rejection", problem.Message)
}
}

View File

@@ -94,7 +94,7 @@ func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--format", "table", "--max", "1",
"+triage", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
@@ -113,6 +113,23 @@ func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
}
}
func TestMailTriageJSONDoesNotEmitReadHint(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--format", "json", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
}
reg.Verify(t)
if strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("json output must not emit table read hint\nstderr=%s", stderr.String())
}
}
func TestMailMessagesExecuteChunksTwentyOneIDsIntoTwoBatchGetCalls(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stub := &httpmock.Stub{

View File

@@ -7,7 +7,6 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
@@ -48,17 +47,6 @@ const (
triageAPIRetries = 2 // retry count in addition to the first attempt
)
// triageOutput is the structured output for +triage: the message list plus
// pagination live inside data (im/calendar convention), meta is nil. Passing a
// struct (not a map) lets output.toGeneric JSON-round-trip it so ExtractItems
// can find the messages array for table/csv/ndjson rendering.
type triageOutput struct {
Messages []map[string]interface{} `json:"messages"`
Total int `json:"total"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
}
var MailTriage = common.Shortcut{
Service: "mail",
Command: "+triage",
@@ -67,7 +55,7 @@ var MailTriage = common.Shortcut{
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "format", Default: "json", Enum: []string{"json", "pretty", "table", "ndjson", "csv"}, Desc: "output format: json (default) | pretty | table | ndjson | csv"},
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
{Name: "page-size", Type: "int", Desc: "alias for --max"},
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
@@ -150,6 +138,7 @@ var MailTriage = common.Shortcut{
}
mailbox := resolveMailboxID(runtime)
hintIdentityFirst(runtime, mailbox)
outFormat := runtime.Str("format")
query := runtime.Str("query")
if query != "" {
if err := common.RejectDangerousCharsTyped("--query", query); err != nil {
@@ -288,26 +277,26 @@ var MailTriage = common.Shortcut{
msg["mailbox_id"] = mailbox
}
// notice 一律走 stderr不再随 json 带内返回)
if notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
}
// 标准信封输出data = {messages, total, has_more, page_token}(与 calendar
// +search-event / im 等 list 命令一致,分页放 data每条 message 已含 mailbox_id
// meta 为 nil--format pretty 走精排表格table/csv/ndjson 由 ExtractItems 从
// data 对象提取 messages 渲染。用 struct而非 mapoutput.toGeneric 对 struct
// 会做 JSON round-trip把嵌套 messages 归一化为 []interface{}ExtractItems 能正确
// 探测到数组字段(顶层 map 不 round-trip会导致 table/csv/ndjson 拍平成一行)。
runtime.OutFormat(triageOutput{
Messages: messages,
Total: len(messages),
HasMore: hasMore,
PageToken: nextPageToken,
}, nil, func(w io.Writer) {
switch outFormat {
case "json", "data":
outData := map[string]interface{}{
"messages": messages,
"mailbox_id": mailbox,
"count": len(messages),
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
output.PrintJson(runtime.IO().Out, outData)
default: // "table"
if notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
}
if len(messages) == 0 {
fmt.Fprintln(w, "No messages found.")
return
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
return nil
}
var rows []map[string]interface{}
for _, msg := range messages {
@@ -325,31 +314,29 @@ var MailTriage = common.Shortcut{
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
})
// 人类导航提示走 stderr所有格式一致不污染 stdout 的数据)
fmt.Fprintf(runtime.IO().ErrOut, "%d message(s)\n", len(messages))
if hasMore && nextPageToken != "" {
var hint strings.Builder
hint.WriteString("next page: mail +triage")
output.PrintTable(runtime.IO().Out, rows)
fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages))
if hasMore && nextPageToken != "" {
var hint strings.Builder
hint.WriteString("next page: mail +triage")
if mailbox != "me" {
hint.WriteString(" --mailbox " + shellQuote(mailbox))
}
if query != "" {
hint.WriteString(" --query " + shellQuote(query))
}
if filterStr := runtime.Str("filter"); filterStr != "" {
hint.WriteString(" --filter " + shellQuote(filterStr))
}
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
if mailbox != "me" {
hint.WriteString(" --mailbox " + shellQuote(mailbox))
quotedMailbox := shellQuote(mailbox)
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
} else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
}
if query != "" {
hint.WriteString(" --query " + shellQuote(query))
}
if filterStr := runtime.Str("filter"); filterStr != "" {
hint.WriteString(" --filter " + shellQuote(filterStr))
}
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
if mailbox != "me" {
quotedMailbox := shellQuote(mailbox)
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
} else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
}
return nil
},

View File

@@ -1548,9 +1548,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
wantCount: 2,
},
{
name: "list json public mailbox",
name: "list data public mailbox",
mailbox: "shared@company.com",
format: "json",
format: "data",
args: []string{"--filter", `{"folder_id":"INBOX"}`},
register: func(reg *httpmock.Registry, mailbox string) {
registerMailTriageListStub(reg, mailbox, []string{"msg_pub_001"}, false, "")
@@ -1574,7 +1574,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
},
{
name: "empty list json returns empty data array with count 0",
name: "empty list json keeps top-level mailbox",
mailbox: "me",
format: "json",
args: []string{"--filter", `{"folder_id":"INBOX"}`},
@@ -1587,7 +1587,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
tt.register(reg, tt.mailbox)
@@ -1603,8 +1603,11 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
}
data := decodeMailTriageJSONOutput(t, stdout)
if tt.wantNotice != "" && !strings.Contains(stderr.String(), "notice: "+tt.wantNotice) {
t.Fatalf("notice mismatch: got %q, want %q in stderr", stderr.String(), tt.wantNotice)
if data["mailbox_id"] != tt.mailbox {
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
}
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
}
messages := mailTriageMessagesFromOutput(t, data)
if len(messages) != tt.wantCount {
@@ -1619,69 +1622,6 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
}
}
// 空收件箱也必须显式返回 data.total=0data 为普通 maptotal 恒在),
// 调用方可稳定读取 .data.total无需区分“缺失”与“0”。
func TestMailTriageEmptyResultEmitsDataTotalZero(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageListStub(reg, "me", nil, false, "")
if err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--format", "json", "--filter", `{"folder_id":"INBOX"}`,
}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeMailTriageJSONOutput(t, stdout)
if messages := mailTriageMessagesFromOutput(t, data); len(messages) != 0 {
t.Fatalf("expected empty messages array, got %d", len(messages))
}
dataObj := mailTriageDataObjFromOutput(t, data)
total, ok := dataObj["total"]
if !ok {
t.Fatalf("empty result must still include data.total, got data=%#v", dataObj)
}
if total != float64(0) {
t.Fatalf("empty result data.total must be 0, got %v", total)
}
}
// 回归:--format ndjson 必须逐条输出 messageExtractItems 从 data 对象提取
// messages 数组),而不是把整个 {messages,total,...} 包装体打成一行。若 messages
// 不是 []interface{}FindArrayField 探测不到数组字段,会退化成整包一行。
func TestMailTriageNdjsonEmitsOneMessagePerLine(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageListStub(reg, "me", []string{"m1", "m2"}, false, "")
registerMailTriageBatchStub(reg, "me", []map[string]interface{}{
mailTriageBatchMessage("m1", "Subject One"),
mailTriageBatchMessage("m2", "Subject Two"),
})
if err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--format", "ndjson", "--filter", `{"folder_id":"INBOX"}`,
}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 ndjson lines (one per message), got %d:\n%s", len(lines), stdout.String())
}
for i, line := range lines {
var obj map[string]interface{}
if err := json.Unmarshal([]byte(line), &obj); err != nil {
t.Fatalf("ndjson line %d is not valid JSON: %v\n%s", i, err, line)
}
if _, ok := obj["message_id"]; !ok {
t.Fatalf("ndjson line %d must be a message object with message_id, not the wrapper: %s", i, line)
}
if _, isWrapper := obj["messages"]; isWrapper {
t.Fatalf("ndjson line must be a single message, not the {messages,...} wrapper: %s", line)
}
}
}
// TestMailTriageMissingMessageMetadataStillGetsMailboxID verifies fallback rows keep mailbox IDs.
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
@@ -1715,40 +1655,6 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
}
}
// TestMailTriageDefaultFormatIsJSON verifies that with no --format flag the
// command defaults to json and prints the paginated object to stdout, not the
// human table.
func TestMailTriageDefaultFormatIsJSON(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageListStub(reg, "me", []string{"msg_ok"}, false, "")
registerMailTriageBatchStub(reg, "me", []map[string]interface{}{
mailTriageBatchMessage("msg_ok", "Present"),
})
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage",
"--filter", `{"folder_id":"INBOX"}`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeMailTriageJSONOutput(t, stdout)
if data["ok"] != true {
t.Fatalf("default output must be ok/data envelope, got %#v", data)
}
messages := mailTriageMessagesFromOutput(t, data) // reads .data.messages
if len(messages) != 1 {
t.Fatalf("expected 1 message, got %d", len(messages))
}
dataObj := mailTriageDataObjFromOutput(t, data)
if _, ok := dataObj["total"]; !ok {
t.Fatalf("data.total missing: %#v", dataObj)
}
}
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
tests := []struct {
@@ -1772,7 +1678,7 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
mailTriageBatchMessage("msg_001", "Table message"),
})
args := []string{"+triage", "--format", "pretty", "--max", "1", "--filter", `{"folder_id":"INBOX"}`}
args := []string{"+triage", "--max", "1", "--filter", `{"folder_id":"INBOX"}`}
if tt.mailbox != "me" {
args = append(args, "--mailbox", tt.mailbox)
}
@@ -1813,7 +1719,6 @@ func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
if err := runMountedMailShortcut(t, MailTriage, []string{
"+triage",
"--format", "pretty",
"--query", strings.Repeat("q", 81),
}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1837,37 +1742,24 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
return data
}
// mailTriageMessagesFromOutput extracts triage messages as object maps from
// the envelope's data object (.data.messages).
// mailTriageMessagesFromOutput extracts triage messages as object maps.
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
t.Helper()
dataObj := mailTriageDataObjFromOutput(t, data)
rawMessages, ok := dataObj["messages"].([]interface{})
rawMessages, ok := data["messages"].([]interface{})
if !ok {
t.Fatalf("data.messages type mismatch: %T", dataObj["messages"])
t.Fatalf("messages type mismatch: %T", data["messages"])
}
messages := make([]map[string]interface{}, 0, len(rawMessages))
for i, item := range rawMessages {
msg, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("data.messages[%d] type mismatch: %T", i, item)
t.Fatalf("messages[%d] type mismatch: %T", i, item)
}
messages = append(messages, msg)
}
return messages
}
// mailTriageDataObjFromOutput extracts the envelope's data object
// ({messages, total, has_more, page_token}); pagination lives here (im-style).
func mailTriageDataObjFromOutput(t *testing.T, data map[string]interface{}) map[string]interface{} {
t.Helper()
dataObj, _ := data["data"].(map[string]interface{})
if dataObj == nil {
t.Fatalf("data object missing in envelope: %#v", data)
}
return dataObj
}
func registerMailTriageListStub(reg *httpmock.Registry, mailbox string, items []string, hasMore bool, pageToken string) {
data := map[string]interface{}{
"items": items,
@@ -2068,8 +1960,7 @@ func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
if len(messages) != 5 {
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
}
dataObj := mailTriageDataObjFromOutput(t, data)
if got, ok := dataObj["has_more"]; ok && got == true {
if got := data["has_more"]; got != false {
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
}
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are

View File

@@ -99,7 +99,7 @@ var MailWatch = common.Shortcut{
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "format", Default: "json", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "format", Default: "data", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
@@ -332,7 +332,7 @@ var MailWatch = common.Shortcut{
output.PrintError(errOut, fmt.Sprintf("failed to write event file: %v", writeErr))
}
}
output.PrintNdjson(out, watchFailureOutputValue(outFormat, string(runtime.As()), failureData))
output.PrintJson(out, failureData)
return
}
}
@@ -385,7 +385,12 @@ var MailWatch = common.Shortcut{
}
}
output.PrintNdjson(out, watchOutputValue(outFormat, string(runtime.As()), outputData))
switch outFormat {
case "json", "":
output.PrintNdjson(out, output.Envelope{OK: true, Identity: string(runtime.As()), Data: outputData})
case "data":
output.PrintNdjson(out, outputData)
}
}
rawHandler := func(ctx context.Context, event *larkevent.EventReq) error {
@@ -682,33 +687,6 @@ func minimalWatchMessage(message map[string]interface{}) map[string]interface{}
return out
}
// watchOutputValue selects the per-event value that +watch prints as NDJSON:
// "data" emits the bare payload; every other format (json — the default) wraps
// it in an ok/identity/data envelope. Extracted from Execute so the default
// envelope behavior is unit-testable without a live WebSocket.
func watchOutputValue(outFormat, identity string, outputData interface{}) interface{} {
if outFormat == "data" {
return outputData
}
return output.Envelope{OK: true, Identity: identity, Data: outputData}
}
// watchFailureOutputValue frames a fetch-failure payload for the watch stream,
// mirroring watchOutputValue so the default json stream stays one JSON object
// per line: bare payload for --format data, identity-tagged single line for the
// default json envelope. failureData already carries {ok:false, error, ...}.
func watchFailureOutputValue(outFormat, identity string, failureData map[string]interface{}) interface{} {
if outFormat == "data" || identity == "" {
return failureData
}
enriched := make(map[string]interface{}, len(failureData)+1)
for k, v := range failureData {
enriched[k] = v
}
enriched["identity"] = identity
return enriched
}
func watchFetchFailureValue(messageID, fetchFormat string, err error, eventBody map[string]interface{}) map[string]interface{} {
payload := map[string]interface{}{
"ok": false,

View File

@@ -20,7 +20,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
@@ -886,145 +885,3 @@ func dryRunAPIsForMailWatchTest(t *testing.T, dry *common.DryRunAPI) []struct {
}
return payload.API
}
// TestWatchOutputValueDefaultIsJSONEnvelope verifies +watch's default format
// (json) wraps each event in an ok/identity/data envelope, while --format data
// emits the bare payload. Covers the default-format behavior without a live
// WebSocket (addresses the coderabbitai review ask).
func TestWatchOutputValueDefaultIsJSONEnvelope(t *testing.T) {
payload := map[string]interface{}{"message": map[string]interface{}{"message_id": "m1"}}
// default (json) → ok/identity/data envelope
b, err := json.Marshal(watchOutputValue("json", "user", payload))
if err != nil {
t.Fatalf("marshal json value: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(b, &env); err != nil {
t.Fatalf("unmarshal json value: %v", err)
}
if env["ok"] != true {
t.Fatalf("default json must have ok:true, got %s", b)
}
if env["identity"] != "user" {
t.Fatalf("default json must carry identity, got %s", b)
}
if _, ok := env["data"]; !ok {
t.Fatalf("default json must have data field, got %s", b)
}
// empty format (safety) also defaults to the json envelope
b3, err := json.Marshal(watchOutputValue("", "bot", payload))
if err != nil {
t.Fatalf("marshal empty-format value: %v", err)
}
var env3 map[string]interface{}
if err := json.Unmarshal(b3, &env3); err != nil {
t.Fatalf("unmarshal empty-format value: %v", err)
}
if env3["ok"] != true {
t.Fatalf("empty format should default to json envelope, got %s", b3)
}
// --format data → bare payload, no envelope
b2, err := json.Marshal(watchOutputValue("data", "user", payload))
if err != nil {
t.Fatalf("marshal data value: %v", err)
}
var bare map[string]interface{}
if err := json.Unmarshal(b2, &bare); err != nil {
t.Fatalf("unmarshal data value: %v", err)
}
if _, hasOK := bare["ok"]; hasOK {
t.Fatalf("--format data must be bare (no envelope), got %s", b2)
}
if _, hasMsg := bare["message"]; !hasMsg {
t.Fatalf("--format data payload should carry message, got %s", b2)
}
}
// P1: fetch 失败分支必须与成功分支同一 NDJSON 框架 —— 默认 json 输出一行
// ok:false + identity + error--format data 输出一行裸 failureData绝不能走
// PrintJson 的多行 pretty会破坏默认 json 的 NDJSON 流)。
func TestWatchFailureOutputValueDefaultIsJSONEnvelope(t *testing.T) {
failure := map[string]interface{}{
"ok": false,
"error": map[string]interface{}{"type": "fetch_message_failed", "message_id": "m1"},
}
// default (json) → identity-tagged single-line failure envelope, ok:false preserved
b, err := json.Marshal(watchFailureOutputValue("json", "user", failure))
if err != nil {
t.Fatalf("marshal json failure value: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(b, &env); err != nil {
t.Fatalf("unmarshal json failure value: %v", err)
}
if env["ok"] != false {
t.Fatalf("failure json must keep ok:false, got %s", b)
}
if env["identity"] != "user" {
t.Fatalf("failure json must carry identity, got %s", b)
}
if _, ok := env["error"]; !ok {
t.Fatalf("failure json must carry error, got %s", b)
}
// --format data → bare failure payload, no identity injected
b2, err := json.Marshal(watchFailureOutputValue("data", "user", failure))
if err != nil {
t.Fatalf("marshal data failure value: %v", err)
}
var bare map[string]interface{}
if err := json.Unmarshal(b2, &bare); err != nil {
t.Fatalf("unmarshal data failure value: %v", err)
}
if _, hasIdentity := bare["identity"]; hasIdentity {
t.Fatalf("--format data failure must not inject identity, got %s", b2)
}
if bare["ok"] != false {
t.Fatalf("--format data failure should carry ok:false payload, got %s", b2)
}
}
// P1: 通过真实的 output.PrintNdjson335 行调用的同一函数)验证失败分支输出恰好
// 一行合法 NDJSON —— 守住"失败行不破坏流"的契约。若生产端把 PrintNdjson 换回
// 多行 PrintJson这里断言的行数就会 >1 而失败(上面的 value 级测试抓不到这点,
// 因为它自己 json.Marshal不经过打印函数
func TestWatchFailurePrintsSingleNDJSONLine(t *testing.T) {
failure := map[string]interface{}{
"ok": false,
"error": map[string]interface{}{"type": "fetch_message_failed", "message_id": "m1"},
}
// default json一行带 identity 的 ok:false 信封
var buf bytes.Buffer
output.PrintNdjson(&buf, watchFailureOutputValue("json", "user", failure))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 1 {
t.Fatalf("failure output must be exactly one NDJSON line, got %d:\n%s", len(lines), buf.String())
}
var env map[string]interface{}
if err := json.Unmarshal([]byte(lines[0]), &env); err != nil {
t.Fatalf("failure line must be valid JSON: %v\n%s", err, lines[0])
}
if env["ok"] != false || env["identity"] != "user" || env["error"] == nil {
t.Fatalf("failure line must carry ok:false + identity + error, got: %s", lines[0])
}
// --format data一行裸 failureData不注入 identity
buf.Reset()
output.PrintNdjson(&buf, watchFailureOutputValue("data", "user", failure))
dataLines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(dataLines) != 1 {
t.Fatalf("--format data failure must be exactly one NDJSON line, got %d:\n%s", len(dataLines), buf.String())
}
var bare map[string]interface{}
if err := json.Unmarshal([]byte(dataLines[0]), &bare); err != nil {
t.Fatalf("data failure line must be valid JSON: %v\n%s", err, dataLines[0])
}
if _, hasIdentity := bare["identity"]; hasIdentity {
t.Fatalf("--format data failure line must not inject identity, got: %s", dataLines[0])
}
}

View File

@@ -30,6 +30,7 @@ lark-cli auth login --domain apps
|---|---|---|
| 创建**新**应用资产、拿 app_id | `+create` | [`lark-apps-create.md`](references/lark-apps-create.md) |
| 找已有 app_id、按名字过滤应用 | `+list --keyword <name>` | [`lark-apps-list.md`](references/lark-apps-list.md) |
| 查单个应用详情(类型、名称、发布状态等) | `+get --app-id <app_id>` | [`lark-apps-get.md`](references/lark-apps-get.md) |
| 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) |
| 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`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) |

View File

@@ -0,0 +1,43 @@
# apps +get
按 app_id 查询单个应用详情。运行时命令事实以 `lark-cli apps +get --help` 为准。
## 何时用
需要查看一个应用的类型、名称、描述、发布状态等详情时使用。如果只是按应用名模糊搜索定位 app_id`+list --keyword`
## 命令骨架
- 必填:`--app-id`
- 返回应用的完整信息:`app_id``app_type``name``description``icon_url``created_at``updated_at``is_published`
## 示例
```bash
lark-cli apps +get --app-id app_xxx
lark-cli apps +get --app-id app_xxx --dry-run
lark-cli apps +get --app-id app_xxx -q '.data.app.app_type'
```
## 输出契约
- 成功读取 `data.app` 对象,包含以下字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `app_id` | string | 应用唯一标识 |
| `app_type` | string | 应用类型(如 HTML、FULL_STACK、MODERN_HTML |
| `name` | string | 应用显示名称 |
| `description` | string | 应用功能说明 |
| `icon_url` | string | 应用图标 URL |
| `created_at` | string | 创建时间ISO 8601 UTC |
| `updated_at` | string | 最后更新时间ISO 8601 UTC |
| `is_published` | boolean | 是否已发布 |
- pretty 输出展示核心字段:`app_id``app_type``name``is_published``updated_at`
- `is_published=true` 只代表应用历史上有发布版本,不代表最新代码已部署。
## Agent 规则
- 用户已有 `app_id` 想查看详情时用 `+get`;只有应用名时用 `+list --keyword`
- 不要把 `cli_` 开头的飞书应用 ID 传给 `+get`,只接受 `app_` 开头的应用 ID。

View File

@@ -23,8 +23,13 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
## 输出契约
- 成功默认 JSON envelope 只关心 `data.url`;这是本轮 HTML 发布后的发布态访问链接。
- pretty 输出为 `url: <url>`,适合人看;自动化取字段用 JSON 或 `--jq '.data.url'`
根据应用类型,输出字段不同:
- **静态 HTML 应用**`data.url` 是本轮发布后的访问链接,一步完成发布。
- **其他 HTML 应用**`data.release_id` 是发布标识,命令内部已完成产物上传和发布创建。用 `+release-get --app-id <app_id> --release-id <release_id>` 轮询发布状态直到 `finished`
判断走哪条路径:有 `url` 字段说明已直接发布完成;有 `release_id` 字段说明需要用 `+release-get` 轮询。
- 业务失败如构建失败、应用不存在通常带 `error.hint`;优先转述 hint。网络/服务端失败则建议稍后重试。
## 链接边界

View File

@@ -10,7 +10,6 @@
- 必填:`--app-id`
- 可选:`--dir`clone 目标目录;省略时默认 `./<app-id>`
- 可选:`--template`,空仓库脚手架模板;省略时当前回退 `nestjs-react-fullstack`
- 固定 checkout 分支:`sprint/default`
- `+init` 会初始化 Git 凭证、clone 仓库、切到工作分支并生成/同步本地项目。
@@ -18,7 +17,7 @@
```bash
lark-cli apps +init --app-id app_xxx --dir ./my-app
lark-cli apps +init --app-id app_xxx --dir /absolute/path/my-app --template nestjs-react-fullstack
lark-cli apps +init --app-id app_xxx --dir /absolute/path/my-app
lark-cli apps +init --app-id app_xxx --dir ./my-app --dry-run
```

View File

@@ -21,8 +21,10 @@ lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run
## 输出契约
- 成功读取 `data.release_id``data.status``release_id` 是后续 `+release-get` 的入参。
- 成功读取 `data.release_id``data.status``data.sync``release_id` 是后续 `+release-get` 的入参。
- `sync=true` 表示同步部署(服务端等待部署完成后才返回),`sync=false` 或缺失表示异步部署。
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min整体超时时间大约 5min。
- `status=finished` 表示部署已完成(同步部署时可能直接返回此状态)。
- `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。
## Agent 规则

View File

@@ -8,7 +8,7 @@
## 用法
```bash
# 默认:收件箱邮件(默认 20 条,默认 json 信封输出
# 默认:收件箱邮件(默认 20 条,默认table 格式
lark-cli mail +triage
# 查看收件箱未读
@@ -31,12 +31,12 @@ lark-cli mail +triage --filter '{"folder":"flagged"}'
lark-cli mail +triage --filter '{"label":"important"}'
lark-cli mail +triage --filter '{"label":"重要邮件"}'
# json 输出配合 jq消息数组在 .data.messages
lark-cli mail +triage --format json | jq '.data.messages[].subject'
# json/data 格式可配合 jq 处理
lark-cli mail +triage --format json | jq '.messages[].subject'
# 分页:先取 10 条,再用 page_token 翻页
lark-cli mail +triage --max 10 --format json
# 输出 data 中包含 page_token传入下一次请求
# 输出中包含 page_token传入下一次请求
lark-cli mail +triage --page-token 'list:FfccvoqPd...' --max 10 --format json
# --page-size 是 --max 的别名
@@ -49,7 +49,7 @@ lark-cli mail +triage --page-size 10
|------|------|------|
| `--filter <json>` | — | 筛选条件(见下方字段说明) |
| `--query <text>` | — | 全文搜索关键词 |
| `--format <mode>` | `json` | `json`(默认,`{ok,data}` 信封,消息在 `data.messages`/ `pretty`(人类表格)/ `table`·`csv`·`ndjson`(通用渲染);非 Enum 值报错 |
| `--format <mode>` | `table` | `table` / `json` / `data``json``data` 均输出含分页信息的对象) |
| `--max <n>` | `20` | 最大返回条数1-400内部自动分页拉取 |
| `--page-size <n>` | — | `--max` 的别名,两者含义相同;同时指定时 `--page-size` 优先 |
| `--page-token <token>` | — | 上一次响应返回的分页令牌,传入后从该位置继续拉取。令牌带 `search:``list:` 前缀,标识来源路径,不可混用 |
@@ -78,46 +78,48 @@ lark-cli mail +triage --page-size 10
## 输出
### `--format json`(默认)
### `--format json` / `--format data`
`{ok,data}` 信封(与 im 等 list 命令一致);`data` 为对象,消息数组在 `data.messages`,分页信息作为 `data` 的兄弟字段;无 `meta`
两者输出格式相同,均为含分页信息的对象
```json
{
"ok": true,
"data": {
"messages": [
{
"message_id": "SEU2...",
"mailbox_id": "me",
"date": "Fri, 21 Mar 2026 11:40:00 +0800",
"from": "Alice <alice@example.com>",
"subject": "Weekly update",
"labels": "INBOX,UNREAD"
}
],
"total": 20,
"has_more": true,
"page_token": "list:FfccvoqPd_loLhtcRx8cx..."
}
"messages": [
{
"message_id": "SEU2...",
"mailbox_id": "me",
"date": "Fri, 21 Mar 2026 11:40:00 +0800",
"from": "Alice <alice@example.com>",
"subject": "Weekly update",
"labels": "INBOX,UNREAD"
}
],
"mailbox_id": "me",
"count": 20,
"has_more": true,
"page_token": "list:FfccvoqPd_loLhtcRx8cx..."
}
```
- `data.messages[].mailbox_id`:邮箱标识,`mail +message --mailbox` 以保持公共邮箱上下文
- `data.total`:本次返回条数(空结果时为 `0`,始终返回,可稳定读取)
- `data.has_more`:是否还有下一页
- `data.page_token`:传入 `--page-token` 获取下一页;前缀 `search:` / `list:` 标识来源路径,不可混用
- **迁移**:旧的顶层 `.messages` / `.count` / `.has_more` / `.page_token` 已迁到 `.data.messages` / `.data.total` / `.data.has_more` / `.data.page_token``--format data` 已移除(用默认或 `--format json`
- `mailbox_id`当前邮箱标识,用于传递`mail +message --mailbox` 以保持公共邮箱上下文
- `has_more`:是否还有下一页
- `page_token`:传入 `--page-token` 可获取下一页;为空字符串表示已到末尾
- token 前缀 `search:` / `list:` 标识来源 API 路径,不可混用
### `pretty` / `table` / `csv` / `ndjson`
### `table` 格式
`--format pretty` 输出精排人类表格;`--format table` / `csv` / `ndjson` 用通用格式化器渲染 `data.messages``ExtractItems` 自动从 data 对象提取 messages 数组)为表格 / CSV / NDJSON。导航提示计数、下一页、读全文 tip统一输出到 **stderr**(所有格式一致,不污染 stdout 数据)
`page_token` 信息输出在 stderr自动携带 `--query`/`--filter`/`--mailbox` 参数方便续页
```text
15 message(s)
next page: mail +triage --query '合同审批' --page-token 'search:abc123...'
tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>
```
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中。
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中:
```text
next page: mail +triage --mailbox 'shared@example.com' --query '合同审批' --page-token 'search:abc123...'
tip: read full content: single message use mail +message --mailbox 'shared@example.com' --message-id <id>; multiple messages use mail +messages --mailbox 'shared@example.com' --message-ids <id1>,<id2>,<id3>
```
### 搜索分页注意事项

View File

@@ -10,7 +10,7 @@
## 命令
```bash
# 默认:json 信封NDJSON 流)输出 message 元数据
# 默认:表格输出 message 元数据
lark-cli mail +watch
# 仅输出 message 数据jq 友好)
@@ -47,7 +47,7 @@ lark-cli mail +watch --print-output-schema
|------|------|------|
| `--mailbox <id>` | `me` | 订阅目标邮箱 |
| `--msg-format <mode>` | `metadata` | 输出模式:`metadata` / `minimal` / `plain_text_full` / `full` / `event` |
| `--format <mode>` | `json` | 输出样式:`json`默认,带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
| `--format <mode>` | `data` | 输出样式:`json`(带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
| `--folder-ids <json-array>` | — | 文件夹 ID 过滤,如 `["INBOX","SENT"]` |
| `--folders <json-array>` | — | 文件夹名称过滤(与 `--folder-ids` 取并集) |
| `--label-ids <json-array>` | — | 标签 ID 过滤,如 `["FLAGGED","IMPORTANT"]` |