Compare commits

...

7 Commits

6 changed files with 327 additions and 33 deletions

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"os"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -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
}

View File

@@ -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)
}
}

View File

@@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"unicode"
@@ -85,7 +86,7 @@ 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)
template := resolveTemplate(rctx, "")
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)).
@@ -123,16 +124,15 @@ func defaultCloneDir(appID string) string {
}
// 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 {
// An explicit --template wins. When appType is available the template is derived
// from it; otherwise it falls back to defaultTemplate.
func resolveTemplate(rctx *common.RuntimeContext, appType string) string {
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
return t
}
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
if appType != "" {
return appType
}
return defaultTemplate
}
@@ -326,16 +326,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, explicitTemplate 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, explicitTemplate)
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 +352,20 @@ func runScaffold(ctx context.Context, dir, appID, template string) (string, erro
return scaffoldKindUpgrade, nil
}
// scaffoldInitArgs builds the npx argument list for `app init`. An explicit
// template wins; otherwise, when appType is available, it is passed as
// --template; empty appType falls back to --template defaultTemplate.
func scaffoldInitArgs(appType, appID, explicitTemplate string) []string {
base := []string{"-y", "--prefer-online", miaodaCLIPkg, "app", "init"}
if explicitTemplate != "" {
return append(base, "--template", explicitTemplate, "--app-id", appID)
}
if appType != "" {
return append(base, "--template", appType, "--app-id", appID)
}
return append(base, "--template", defaultTemplate, "--app-id", appID)
}
// 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 +457,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 +471,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 +532,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))
explicitTemplate := strings.TrimSpace(rctx.Str("template"))
scaffold, err := runScaffold(ctx, dir, appID, appType, explicitTemplate)
if err != nil {
return err
}
@@ -530,15 +548,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 +556,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)

View File

@@ -48,14 +48,14 @@ func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeC
}
func TestResolveTemplate(t *testing.T) {
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" {
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), ""); got != "foo" {
t.Errorf("explicit --template = %q, want foo", got)
}
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); got != defaultTemplate {
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), ""); 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 {
if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), ""); got != defaultTemplate {
t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate)
}
}
@@ -261,7 +261,7 @@ 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", "", "nestjs-react-fullstack")
if err != nil || kind != "init" {
t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err)
}
@@ -280,7 +280,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", "", "nestjs-react-fullstack")
if err != nil || kind != "upgrade" {
t.Fatalf("kind=%q err=%v, want upgrade", kind, err)
}
@@ -299,7 +299,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", "", "nestjs-react-fullstack"); err != nil {
t.Fatal(err)
}
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
@@ -313,7 +313,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", "", "nestjs-react-fullstack"); err == nil {
t.Error("app init failure must propagate")
}
}
@@ -1250,7 +1250,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", "", "tpl"); err == nil {
t.Error("npx app sync failure must surface as an error")
}
}
@@ -1630,7 +1630,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", "", "nestjs-react-fullstack")
if err == nil {
t.Fatalf("expected error from failing git subprocess")
}
@@ -1645,3 +1645,79 @@ 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, "--template", "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, "--template", "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, "--template", defaultTemplate) {
t.Errorf("expected --template %s in args: %v", defaultTemplate, c)
}
}
func TestRunScaffold_ExplicitTemplateOverride(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", "custom-template")
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, "--template", "custom-template") {
t.Errorf("expected --template custom-template in args: %v", c)
}
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// queryAppType fetches the app's type string ("html", "modern_html",
// "full_stack", etc.) from the server. 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/type", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("GET", path, nil, nil)
if err != nil {
return ""
}
appType, _ := data["app_type"].(string)
return appType
}

View File

@@ -0,0 +1,81 @@
// 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"
)
// newMetaTestRuntime creates a RuntimeContext wired to an httpmock.Registry
// so queryAppType can be tested without a real server.
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/type",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"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_APIError(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_bad/type",
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_MissingField(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_no/type",
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 field missing", result)
}
}