From 2ecaa6bbb536a7ac9e5f4a82c8a32ee48c9a9d51 Mon Sep 17 00:00:00 2001 From: anguohui Date: Mon, 6 Jul 2026 22:36:42 +0800 Subject: [PATCH] refactor(apps): replace appMeta struct with queryAppType string for simpler routing --- shortcuts/apps/apps_html_publish.go | 6 +- shortcuts/apps/apps_init.go | 64 +++++++-------- shortcuts/apps/apps_init_test.go | 43 ++++------ shortcuts/apps/apps_meta.go | 56 ++----------- shortcuts/apps/apps_meta_test.go | 117 +++++----------------------- 5 files changed, 75 insertions(+), 211 deletions(-) diff --git a/shortcuts/apps/apps_html_publish.go b/shortcuts/apps/apps_html_publish.go index 1d5d2209a..1635a52af 100644 --- a/shortcuts/apps/apps_html_publish.go +++ b/shortcuts/apps/apps_html_publish.go @@ -120,10 +120,10 @@ var AppsHTMLPublish = common.Shortcut{ Path: strings.TrimSpace(rctx.Str("path")), } - meta := queryAppMeta(ctx, rctx, spec.AppID) + appType := queryAppType(ctx, rctx, spec.AppID) - // doubao-html (app_type=7, arch_type=4): zip + TOS path - if meta != nil && meta.AppType == 7 && meta.ArchType == 4 { + // doubao-html (modern_html): zip + TOS path + if appType == "modern_html" { tosClient := appsHTMLPublishTOSAPI{runtime: rctx} out, err := runHTMLPublishTOS(ctx, rctx.FileIO(), tosClient, rctx, spec) if err != nil { diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go index f7bcb0520..b386f9fa9 100644 --- a/shortcuts/apps/apps_init.go +++ b/shortcuts/apps/apps_init.go @@ -11,7 +11,7 @@ import ( "os" "os/exec" "path/filepath" - "strconv" + "strings" "unicode" @@ -87,7 +87,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, nil) + 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)). @@ -125,19 +125,17 @@ func defaultCloneDir(appID string) string { } // resolveTemplate returns the scaffold template for an empty-repo `app init`. -// An explicit --template wins. When meta is available the template is derived -// from app_type/arch_type; otherwise it falls back to defaultTemplate. -func resolveTemplate(rctx *common.RuntimeContext, meta *appMeta) 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 } - if meta != nil { - switch { - case meta.AppType == 7 && meta.ArchType == 3: - return "" - case meta.AppType == 7 && meta.ArchType == 4: - return "vite-react" - } + if appType == "html" { + return "" + } + if appType != "" { + return appType } return defaultTemplate } @@ -332,8 +330,8 @@ 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", "upgrade", or "skipped". -func runScaffold(ctx context.Context, dir, appID string, meta *appMeta, explicitTemplate string) (string, error) { - if isStaticHtml(meta) { +func runScaffold(ctx context.Context, dir, appID, appType, explicitTemplate string) (string, error) { + if appType == "html" { return scaffoldKindSkipped, nil } empty, err := isEmptyRepo(ctx, dir) @@ -341,7 +339,7 @@ func runScaffold(ctx context.Context, dir, appID string, meta *appMeta, explicit return "", err } if empty { - args := scaffoldInitArgs(meta, appID, explicitTemplate) + 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)) } @@ -362,19 +360,15 @@ func runScaffold(ctx context.Context, dir, appID string, meta *appMeta, explicit } // scaffoldInitArgs builds the npx argument list for `app init`. An explicit -// template wins; otherwise, when meta is available, --app-type/--arch-type are -// passed so miaoda-cli picks the right scaffold; nil meta falls back to -// --template defaultTemplate. -func scaffoldInitArgs(meta *appMeta, appID, explicitTemplate string) []string { +// 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 meta != nil { - return append(base, - "--app-type", strconv.Itoa(meta.AppType), - "--arch-type", strconv.Itoa(meta.ArchType), - "--app-id", appID) + if appType != "" { + return append(base, "--template", appType, "--app-id", appID) } return append(base, "--template", defaultTemplate, "--app-id", appID) } @@ -470,7 +464,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { return err } - meta := queryAppMeta(ctx, rctx, appID) + 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 @@ -485,11 +479,10 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { "committed": false, "pushed": false, } - if meta != nil { - out["app_type"] = meta.AppType - out["arch_type"] = meta.ArchType + if appType != "" { + out["app_type"] = appType } - if isStaticHtml(meta) { + if appType == "html" { out["env_pulled"] = false out["env_pull_skipped"] = true out["message"] = "Repository already initialized (static HTML app — no env pull needed)." @@ -529,7 +522,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { return appsFailedPreconditionError("git executable not found on PATH"). WithHint("install git and ensure it is on your PATH") } - if !isStaticHtml(meta) { + if appType != "html" { if _, err := exec.LookPath("npx"); err != nil { return appsFailedPreconditionError("npx executable not found on PATH"). WithHint("install Node.js (which provides npx) and ensure it is on your PATH") @@ -560,7 +553,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { initLogf(rctx, "Initializing app code (running miaoda-cli)...") explicitTemplate := strings.TrimSpace(rctx.Str("template")) - scaffold, err := runScaffold(ctx, dir, appID, meta, explicitTemplate) + scaffold, err := runScaffold(ctx, dir, appID, appType, explicitTemplate) if err != nil { return err } @@ -585,14 +578,13 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { "pushed": pushed, "message": "Repository initialized. You can start developing.", } - if meta != nil { - out["app_type"] = meta.AppType - out["arch_type"] = meta.ArchType + if appType != "" { + out["app_type"] = appType } var envFile string var envPulled bool - if isStaticHtml(meta) { + if appType == "html" { out["env_pulled"] = false out["env_pull_skipped"] = true } else { @@ -614,7 +606,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { 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 isStaticHtml(meta) { + if appType == "html" { fmt.Fprintln(w, " (static HTML app — env pull skipped)") } else if envPulled { fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile) diff --git a/shortcuts/apps/apps_init_test.go b/shortcuts/apps/apps_init_test.go index 7e79881e9..feac60e74 100644 --- a/shortcuts/apps/apps_init_test.go +++ b/shortcuts/apps/apps_init_test.go @@ -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"), nil); got != "foo" { + if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), ""); got != "foo" { t.Errorf("explicit --template = %q, want foo", got) } - if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), nil); 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, "", " "), nil); 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", nil, "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", nil, "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", nil, "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", nil, "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", nil, "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", nil, "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") } @@ -1649,8 +1649,7 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) { func TestRunScaffold_StaticHtmlSkipped(t *testing.T) { f := &fakeCommandRunner{} withFakeRunner(t, f) - meta := &appMeta{AppType: 7, ArchType: 3} - kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", meta, "") + kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "html", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1662,11 +1661,10 @@ func TestRunScaffold_StaticHtmlSkipped(t *testing.T) { } } -func TestRunScaffold_DoubaoHtmlPassesTypes(t *testing.T) { +func TestRunScaffold_ModernHtmlPassesTemplate(t *testing.T) { f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}} withFakeRunner(t, f) - meta := &appMeta{AppType: 7, ArchType: 4} - kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", meta, "") + kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "modern_html", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1677,18 +1675,15 @@ func TestRunScaffold_DoubaoHtmlPassesTypes(t *testing.T) { if c == nil { t.Fatal("npx not called") } - if !containsAll(c, "--app-type", "7", "--arch-type", "4") { - t.Errorf("expected --app-type 7 --arch-type 4 in args: %v", c) - } - if containsAll(c, "--template") { - t.Errorf("should not contain --template when meta is present: %v", c) + if !containsAll(c, "--template", "modern_html") { + t.Errorf("expected --template modern_html in args: %v", c) } } -func TestRunScaffold_NilMetaFallback(t *testing.T) { +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", nil, "") + kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1707,8 +1702,7 @@ func TestRunScaffold_NilMetaFallback(t *testing.T) { func TestRunScaffold_ExplicitTemplateOverride(t *testing.T) { f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}} withFakeRunner(t, f) - meta := &appMeta{AppType: 7, ArchType: 4} - kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", meta, "custom-template") + kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "modern_html", "custom-template") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1722,7 +1716,4 @@ func TestRunScaffold_ExplicitTemplateOverride(t *testing.T) { if !containsAll(c, "--template", "custom-template") { t.Errorf("expected --template custom-template in args: %v", c) } - if containsAll(c, "--app-type") { - t.Errorf("--template should override --app-type: %v", c) - } } diff --git a/shortcuts/apps/apps_meta.go b/shortcuts/apps/apps_meta.go index 6466770de..001036c15 100644 --- a/shortcuts/apps/apps_meta.go +++ b/shortcuts/apps/apps_meta.go @@ -5,61 +5,21 @@ package apps import ( "context" - "encoding/json" "fmt" - "strconv" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) -// appMeta holds the numeric app_type and arch_type returned by the server. -type appMeta struct { - AppType int - ArchType int -} - -// queryAppMeta fetches the app's metadata (numeric app_type and arch_type) -// from the server. Returns nil when the API is unavailable or returns an -// error — callers fall back to legacy behavior. -func queryAppMeta(ctx context.Context, rctx *common.RuntimeContext, appID string) *appMeta { - path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID)) +// 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 nil + return "" } - app, _ := data["app"].(map[string]interface{}) - if app == nil { - return nil - } - appType, ok1 := toInt(app["app_type"]) - archType, ok2 := toInt(app["arch_type"]) - if !ok1 || !ok2 { - return nil - } - return &appMeta{AppType: appType, ArchType: archType} -} - -// toInt converts a JSON number (float64 or json.Number) to int. -func toInt(v interface{}) (int, bool) { - switch n := v.(type) { - case float64: - return int(n), true - case int: - return n, true - case json.Number: - i, err := strconv.Atoi(n.String()) - if err != nil { - return 0, false - } - return i, true - default: - return 0, false - } -} - -// isStaticHtml reports whether the app is a legacy static-HTML app -// (app_type=7, arch_type=3) that has no template and no env vars. -func isStaticHtml(meta *appMeta) bool { - return meta != nil && meta.AppType == 7 && meta.ArchType == 3 + appType, _ := data["app_type"].(string) + return appType } diff --git a/shortcuts/apps/apps_meta_test.go b/shortcuts/apps/apps_meta_test.go index 515e5f359..f8786b172 100644 --- a/shortcuts/apps/apps_meta_test.go +++ b/shortcuts/apps/apps_meta_test.go @@ -5,7 +5,6 @@ package apps import ( "context" - "encoding/json" "testing" "github.com/spf13/cobra" @@ -17,7 +16,7 @@ import ( ) // newMetaTestRuntime creates a RuntimeContext wired to an httpmock.Registry -// so queryAppMeta can be tested without a real server. +// 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"} @@ -30,131 +29,53 @@ func newMetaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registr return rt, reg } -func TestQueryAppMeta_Success(t *testing.T) { +func TestQueryAppType_Success(t *testing.T) { rt, reg := newMetaTestRuntime(t) reg.Register(&httpmock.Stub{ Method: "GET", - URL: "/open-apis/spark/v1/apps/app_test123", + URL: "/open-apis/spark/v1/apps/app_test/type", Body: map[string]interface{}{ "code": float64(0), "data": map[string]interface{}{ - "app": map[string]interface{}{ - "app_type": float64(7), - "arch_type": float64(4), - "name": "Test App", - }, + "app_type": "modern_html", }, }, }) - meta := queryAppMeta(context.Background(), rt, "app_test123") - if meta == nil { - t.Fatal("expected non-nil appMeta") - } - if meta.AppType != 7 { - t.Errorf("AppType = %d, want 7", meta.AppType) - } - if meta.ArchType != 4 { - t.Errorf("ArchType = %d, want 4", meta.ArchType) + result := queryAppType(context.Background(), rt, "app_test") + if result != "modern_html" { + t.Errorf("queryAppType = %q, want modern_html", result) } } -func TestQueryAppMeta_APIError(t *testing.T) { +func TestQueryAppType_APIError(t *testing.T) { rt, reg := newMetaTestRuntime(t) reg.Register(&httpmock.Stub{ Method: "GET", - URL: "/open-apis/spark/v1/apps/app_err", - Body: map[string]interface{}{ - "code": float64(500100), - "msg": "internal server error", - }, + URL: "/open-apis/spark/v1/apps/app_bad/type", + Status: 500, + Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"}, }) - meta := queryAppMeta(context.Background(), rt, "app_err") - if meta != nil { - t.Fatalf("expected nil for API error, got %+v", meta) + result := queryAppType(context.Background(), rt, "app_bad") + if result != "" { + t.Errorf("queryAppType = %q, want empty on error", result) } } -func TestQueryAppMeta_MissingFields(t *testing.T) { +func TestQueryAppType_MissingField(t *testing.T) { rt, reg := newMetaTestRuntime(t) reg.Register(&httpmock.Stub{ Method: "GET", - URL: "/open-apis/spark/v1/apps/app_partial", - Body: map[string]interface{}{ - "code": float64(0), - "data": map[string]interface{}{ - "app": map[string]interface{}{ - "name": "Partial App", - }, - }, - }, - }) - - meta := queryAppMeta(context.Background(), rt, "app_partial") - if meta != nil { - t.Fatalf("expected nil when app_type/arch_type are missing, got %+v", meta) - } -} - -func TestQueryAppMeta_NoAppObject(t *testing.T) { - rt, reg := newMetaTestRuntime(t) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/spark/v1/apps/app_noobj", + URL: "/open-apis/spark/v1/apps/app_no/type", Body: map[string]interface{}{ "code": float64(0), "data": map[string]interface{}{}, }, }) - meta := queryAppMeta(context.Background(), rt, "app_noobj") - if meta != nil { - t.Fatalf("expected nil when app object is absent, got %+v", meta) - } -} - -func TestIsStaticHtml(t *testing.T) { - tests := []struct { - name string - meta *appMeta - want bool - }{ - {"nil meta", nil, false}, - {"static html (7,3)", &appMeta{AppType: 7, ArchType: 3}, true}, - {"full stack (7,4)", &appMeta{AppType: 7, ArchType: 4}, false}, - {"other type (3,0)", &appMeta{AppType: 3, ArchType: 0}, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := isStaticHtml(tt.meta); got != tt.want { - t.Errorf("isStaticHtml(%+v) = %v, want %v", tt.meta, got, tt.want) - } - }) - } -} - -func TestToInt(t *testing.T) { - tests := []struct { - name string - input interface{} - wantN int - wantOK bool - }{ - {"float64", float64(42), 42, true}, - {"int", 7, 7, true}, - {"json.Number", json.Number("99"), 99, true}, - {"json.Number non-int", json.Number("abc"), 0, false}, - {"string", "7", 0, false}, - {"nil", nil, 0, false}, - {"bool", true, 0, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - n, ok := toInt(tt.input) - if n != tt.wantN || ok != tt.wantOK { - t.Errorf("toInt(%v) = (%d, %v), want (%d, %v)", tt.input, n, ok, tt.wantN, tt.wantOK) - } - }) + result := queryAppType(context.Background(), rt, "app_no") + if result != "" { + t.Errorf("queryAppType = %q, want empty when field missing", result) } }