mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 18:34:03 +08:00
Compare commits
21 Commits
sun/docs-u
...
feat/douba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
705f449472 | ||
|
|
4a8b64b5ae | ||
|
|
2e653d4f2d | ||
|
|
d2e10b39a3 | ||
|
|
6d3275f35d | ||
|
|
994ba94aba | ||
|
|
f60ff95c22 | ||
|
|
577c4df634 | ||
|
|
a0bdde46b0 | ||
|
|
b16f1d34f3 | ||
|
|
fc1aa02aa8 | ||
|
|
92bed1b424 | ||
|
|
7e794a19b5 | ||
|
|
5142fd0c65 | ||
|
|
3bff980d7a | ||
|
|
b1819e63b2 | ||
|
|
e3ba34df8f | ||
|
|
fe4d55ee88 | ||
|
|
800eeaf50e | ||
|
|
19e91ec7b0 | ||
|
|
e1e393146a |
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -29,7 +30,7 @@ var AppsCreate = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "app display name", Required: true},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack", "modern_html"}},
|
||||
{Name: "description", Desc: "app description"},
|
||||
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
|
||||
},
|
||||
@@ -71,5 +72,8 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
|
||||
body["icon_url"] = icon
|
||||
}
|
||||
if agent := os.Getenv("LARKSUITE_CLI_AGENT"); agent != "" {
|
||||
body["source_agent"] = agent
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -273,3 +273,92 @@ 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", "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", "")
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
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"
|
||||
@@ -119,15 +122,29 @@ 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
|
||||
}
|
||||
out["app_id"] = spec.AppID
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "app_id: %s\n", spec.AppID)
|
||||
if url, ok := out["url"].(string); ok && url != "" {
|
||||
fmt.Fprintf(w, "url: %s\n", url)
|
||||
}
|
||||
if tosPath, ok := out["tos_path"].(string); ok && tosPath != "" {
|
||||
fmt.Fprintf(w, "tos_path: %s\n", tosPath)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
@@ -256,3 +273,78 @@ 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) {
|
||||
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), spec.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
return nil, oversizeHTMLFilesError(hits)
|
||||
}
|
||||
var rawTotal int64
|
||||
for _, c := range candidates {
|
||||
rawTotal += c.Size
|
||||
}
|
||||
if rawTotal > maxHTMLPublishRawBytes {
|
||||
return nil, appsValidationParamError("--path",
|
||||
"--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes).
|
||||
WithHint("reduce --path contents or choose a smaller subdirectory before packaging")
|
||||
}
|
||||
|
||||
tarball, err := buildHTMLPublishTarball(rctx.FileIO(), candidates)
|
||||
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")
|
||||
}
|
||||
|
||||
// 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("POST", preReleasePath, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params, _ := preData["params"].(map[string]interface{})
|
||||
if params == nil {
|
||||
return nil, appsSubprocessEnvelopeError("pre_release returned no params")
|
||||
}
|
||||
uploadURL, _ := params["upload_url"].(string)
|
||||
tosPath, _ := params["tos_path"].(string)
|
||||
if uploadURL == "" || tosPath == "" {
|
||||
return nil, appsSubprocessEnvelopeError("pre_release params 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.MethodPost, uploadURL, bytes.NewReader(tarball.Body))
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "build TOS upload request").WithCause(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"app_id": spec.AppID,
|
||||
"tos_path": tosPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -38,8 +39,7 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
|
||||
defaultTemplate = "nestjs-react-fullstack"
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.20-alpha.dd573f8"
|
||||
metaRelPath = ".spark/meta.json"
|
||||
steeringRelPath = ".agent/skills/steering"
|
||||
seedReadme = "README.md"
|
||||
@@ -75,7 +75,7 @@ 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")) == "" {
|
||||
@@ -85,14 +85,13 @@ var AppsInit = common.Shortcut{
|
||||
},
|
||||
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 +121,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,
|
||||
@@ -326,16 +311,14 @@ 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
|
||||
@@ -354,6 +337,22 @@ func runScaffold(ctx context.Context, dir, appID, template string) (string, erro
|
||||
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.
|
||||
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
|
||||
base := []string{"-y", "--prefer-online", 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)
|
||||
}
|
||||
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 +444,8 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
appType := queryAppType(ctx, rctx, appID)
|
||||
|
||||
// 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 +458,9 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
"committed": false,
|
||||
"pushed": false,
|
||||
}
|
||||
if appType != "" {
|
||||
out["app_type"] = appType
|
||||
}
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
@@ -515,7 +519,8 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
|
||||
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
|
||||
sourcePath := strings.TrimSpace(rctx.Str("source-path"))
|
||||
scaffold, err := runScaffold(ctx, dir, appID, appType, sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -530,15 +535,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,15 +543,25 @@ 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 appType != "" {
|
||||
out["app_type"] = appType
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -299,7 +273,7 @@ func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) {
|
||||
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 +287,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,12 +316,12 @@ 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 --template 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") {
|
||||
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
t.Errorf("app init missing expected --template fallback args: %v", c)
|
||||
} else if containsAll(c, "--local") {
|
||||
t.Errorf("app init must NOT carry --local: %v", c)
|
||||
@@ -751,22 +725,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 +745,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 +1207,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 +1587,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 +1602,195 @@ 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 --template 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 --template 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 --template 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 --template 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 --template modern_html --app-id app_x, 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 TestScaffoldInitArgs_EmptyFallback(t *testing.T) {
|
||||
args := scaffoldInitArgs("", "app_x", "")
|
||||
if !containsAll(args, "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
t.Errorf("expected --template 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 --template 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 --template modern_html, got %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
52
shortcuts/apps/apps_meta.go
Normal file
52
shortcuts/apps/apps_meta.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// appInfo represents the app object returned by GET /open-apis/spark/v1/apps/{appID}.
|
||||
type appInfo struct {
|
||||
AppID string `json:"app_id"`
|
||||
AppType string `json:"app_type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IconURL string `json:"icon_url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return ""
|
||||
}
|
||||
appRaw, _ := data["app"].(map[string]interface{})
|
||||
if appRaw == nil {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(appRaw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var info appInfo
|
||||
if err := json.Unmarshal(b, &info); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(info.AppType)
|
||||
}
|
||||
148
shortcuts/apps/apps_meta_test.go
Normal file
148
shortcuts/apps/apps_meta_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
|
||||
{Name: "tos-path", Desc: "TOS file path from +html-publish; when provided, deploys from TOS instead of git branch"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
@@ -39,26 +40,29 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
branch := strings.TrimSpace(rctx.Str("branch"))
|
||||
tosPath := strings.TrimSpace(rctx.Str("tos-path"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Create a release").
|
||||
Body(buildPublishBody(branch))
|
||||
Body(buildPublishBody(branch, tosPath))
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
branch := strings.TrimSpace(rctx.Str("branch"))
|
||||
tosPath := strings.TrimSpace(rctx.Str("tos-path"))
|
||||
path := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, buildPublishBody(branch))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, buildPublishBody(branch, tosPath))
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the push was rejected (non-fast-forward), sync first with `git pull --rebase origin sprint/default` then retry; inspect the failure via `lark-cli apps +release-get --app-id "+appID+" --release-id <release_id>`")
|
||||
}
|
||||
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
|
||||
},
|
||||
@@ -66,10 +70,13 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
|
||||
// buildPublishBody builds the create-release request body. app_id is in the
|
||||
// path, not the body. branch is included only when non-empty.
|
||||
func buildPublishBody(branch string) map[string]interface{} {
|
||||
func buildPublishBody(branch, tosPath string) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if branch != "" {
|
||||
body["branch"] = branch
|
||||
}
|
||||
if tosPath != "" {
|
||||
body["tosPath"] = tosPath
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func TestBuildPublishBody(t *testing.T) {
|
||||
// branch included when non-empty; app_id is NOT in body (it's in the path)
|
||||
b := buildPublishBody("feat/devops")
|
||||
b := buildPublishBody("feat/devops", "")
|
||||
if b["branch"] != "feat/devops" {
|
||||
t.Errorf("body = %v", b)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func TestBuildPublishBody(t *testing.T) {
|
||||
t.Errorf("app_id must not be in body, got %v", b)
|
||||
}
|
||||
// branch omitted when empty
|
||||
b2 := buildPublishBody("")
|
||||
b2 := buildPublishBody("", "")
|
||||
if _, ok := b2["branch"]; ok {
|
||||
t.Errorf("branch should be omitted when empty, got %v", b2)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
Reference in New Issue
Block a user