From 25e034e33c9c402be1402ae5f043d8328db5eae3 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 10:22:19 +0800 Subject: [PATCH 01/51] feat(apps): add +app-dev-init-app shortcut --- shortcuts/apps/apps_app_dev_init_app.go | 206 +++++++++++ shortcuts/apps/apps_app_dev_init_app_test.go | 343 +++++++++++++++++++ 2 files changed, 549 insertions(+) create mode 100644 shortcuts/apps/apps_app_dev_init_app.go create mode 100644 shortcuts/apps/apps_app_dev_init_app_test.go diff --git a/shortcuts/apps/apps_app_dev_init_app.go b/shortcuts/apps/apps_app_dev_init_app.go new file mode 100644 index 0000000000..ebb36bfd7f --- /dev/null +++ b/shortcuts/apps/apps_app_dev_init_app.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// Templates provided by @lark-apaas/miaoda-cli for the artifact-hosting mode. +// The CLI only maps --type to a template name; template content is owned and +// iterated by the miaoda-cli package. +const ( + appDevTemplateFrontend = "react-standard-webapp" + appDevTemplateFullstack = "react-express-standard-fullstack" +) + +// appDevLookPath is swappable in tests to simulate a missing npx/npm binary. +var appDevLookPath = exec.LookPath + +// appDevTemplateForType maps the +app-dev-init-app --type value to its +// miaoda-cli template name. Unknown types return "". +func appDevTemplateForType(appType string) string { + switch appType { + case "frontend": + return appDevTemplateFrontend + case "full_stack": + return appDevTemplateFullstack + } + return "" +} + +// appDevInitArgs builds the npx argv for scaffolding via miaoda-cli. +// --skip-install keeps the command fast; dependency install is left to the +// user (agents should not block minutes on npm install). +func appDevInitArgs(template string) []string { + return []string{ + "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, + "app", "init", "--template", template, "--skip-install", + } +} + +// resolveAppDevDir returns the scaffold target directory: --dir when set, +// otherwise ./. +func resolveAppDevDir(dir, template string) string { + d := strings.TrimSpace(dir) + if d == "" { + return filepath.Join(".", template) + } + return d +} + +// validateAppDevDir rejects absolute paths and .. traversal in --dir, keeping +// scaffolding inside the working directory. +func validateAppDevDir(dir string) error { + d := strings.TrimSpace(dir) + if d == "" { + return nil + } + if filepath.IsAbs(d) { + return appsValidationParamError("--dir", + "--dir must be a relative path within the current directory, got %q", d) + } + for _, seg := range strings.Split(filepath.Clean(d), string(filepath.Separator)) { + if seg == ".." { + return appsValidationParamError("--dir", + "--dir must not contain .. path traversal, got %q", d) + } + } + return nil +} + +// ensureAppDevDirUsable requires the scaffold target to be absent or an empty +// directory so miaoda-cli never writes into (or over) existing content. +func ensureAppDevDirUsable(dir string) error { + entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard); dir is validated relative-only by validateAppDevDir. + if err != nil { + if os.IsNotExist(err) { + return nil + } + return appsFileIOError(err, "read target directory %s failed: %v", dir, err) + } + if len(entries) > 0 { + return appsFailedPreconditionParamError("--dir", + "target directory %s already exists and is not empty", dir). + WithHint("choose an empty or new directory with --dir, or remove the existing contents first") + } + return nil +} + +// readMetaStack reads /.spark/meta.json and returns its stack field. +// Mirrors readMetaAppID: (value, fileExists, error). +func readMetaStack(dir string) (string, bool, error) { + b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // same rationale as readMetaAppID + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err) + } + var meta map[string]interface{} + if err := json.Unmarshal(b, &meta); err != nil { + return "", true, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err) + } + s, _ := meta["stack"].(string) + return s, true, nil +} + +// AppsAppDevInitApp scaffolds a local web app project via miaoda-cli +// templates (artifact-hosting mode: code stays local, no git, no sandbox). +var AppsAppDevInitApp = common.Shortcut{ + Service: appsService, + Command: "+app-dev-init-app", + Description: "Scaffold a local web app project via miaoda-cli templates (artifact-hosting mode, no git/sandbox, no remote API)", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +app-dev-init-app --type frontend --dir ./my-app", + "Example: lark-cli apps +app-dev-init-app --type full_stack --dry-run", + "The scaffold is local-only: create the Miaoda app later with +create and deploy with +app-dev-publish", + }, + // No remote OAPI is called; explicit []string{} per the convention + // enforced by TestAllShortcutsScopesNotNil. + Scopes: []string{}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "type", Desc: "app type; maps to a miaoda-cli template (frontend=react-standard-webapp, full_stack=react-express-standard-fullstack)", Enum: []string{"frontend", "full_stack"}}, + {Name: "dir", Desc: "target directory, relative path (default ./); must be new or empty"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + appType := strings.TrimSpace(rctx.Str("type")) + if appType == "" { + return appsValidationParamError("--type", "--type is required"). + WithHint("valid values: frontend | full_stack") + } + if err := validateAppDevDir(rctx.Str("dir")); err != nil { + return err + } + if _, err := appDevLookPath("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") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + template := appDevTemplateForType(strings.TrimSpace(rctx.Str("type"))) + dir := resolveAppDevDir(rctx.Str("dir"), template) + dry := common.NewDryRunAPI(). + Desc("Scaffold a local web app project via miaoda-cli (local npx, no remote API)") + dry.Set("command", "npx "+strings.Join(appDevInitArgs(template), " ")) + dry.Set("target_dir", dir) + dry.Set("template", template) + dry.Set("remote_side_effects", "none (local scaffold via npx)") + return dry + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + template := appDevTemplateForType(strings.TrimSpace(rctx.Str("type"))) + dir := resolveAppDevDir(rctx.Str("dir"), template) + if err := ensureAppDevDirUsable(dir); err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:forbidigo // see ensureAppDevDirUsable + return appsFileIOError(err, "create target directory %s failed: %v", dir, err) + } + if _, stderr, err := initRunner.Run(ctx, dir, "npx", appDevInitArgs(template)...); err != nil { + return appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err)). + WithHint("check your network and Node.js version, then retry; the template registry is https://registry.npmmirror.com") + } + // Light acceptance check on the template output: echo the stack from + // .spark/meta.json when present; a missing file is the template's + // contract problem, not a command failure. + stack := template + if s, ok, err := readMetaStack(dir); err == nil && ok && s != "" { + stack = s + } else if err == nil && !ok { + fmt.Fprintf(rctx.IO().ErrOut, "warning: %s missing under %s; the miaoda-cli template should produce it\n", metaRelPath, dir) + } + nextSteps := []string{ + fmt.Sprintf("cd %s && npm install && npm run dev", dir), + "lark-cli apps +create --name , then write the returned app_id into .spark/meta.json", + "run lark-cli apps +app-dev-publish from the project root to build and deploy", + } + data := map[string]interface{}{ + "dir": dir, + "template": template, + "stack": stack, + "next_steps": nextSteps, + } + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "dir: %s\ntemplate: %s\nstack: %s\nnext steps:\n", dir, template, stack) + for _, s := range nextSteps { + fmt.Fprintf(w, " - %s\n", s) + } + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_app_dev_init_app_test.go b/shortcuts/apps/apps_app_dev_init_app_test.go new file mode 100644 index 0000000000..153ff38cfc --- /dev/null +++ b/shortcuts/apps/apps_app_dev_init_app_test.go @@ -0,0 +1,343 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// --- pure-function tests --- + +func TestAppDevTemplateForType(t *testing.T) { + tests := []struct { + name, appType, want string + }{ + {"frontend", "frontend", "react-standard-webapp"}, + {"full_stack", "full_stack", "react-express-standard-fullstack"}, + {"unknown", "html", ""}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := appDevTemplateForType(tt.appType); got != tt.want { + t.Errorf("appDevTemplateForType(%q) = %q, want %q", tt.appType, got, tt.want) + } + }) + } +} + +func TestAppDevInitArgs(t *testing.T) { + got := appDevInitArgs("react-standard-webapp") + want := []string{ + "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, + "app", "init", "--template", "react-standard-webapp", "--skip-install", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("appDevInitArgs = %v, want %v", got, want) + } +} + +func TestResolveAppDevDir(t *testing.T) { + if got := resolveAppDevDir("", "react-standard-webapp"); got != filepath.Join(".", "react-standard-webapp") { + t.Errorf("default dir = %q", got) + } + if got := resolveAppDevDir("./my-app", "react-standard-webapp"); got != "./my-app" { + t.Errorf("explicit dir = %q", got) + } +} + +func TestValidateAppDevDir(t *testing.T) { + for _, ok := range []string{"", "my-app", "./my-app", "a/b"} { + if err := validateAppDevDir(ok); err != nil { + t.Errorf("%q should be valid: %v", ok, err) + } + } + for _, bad := range []string{"/abs", "../x", "a/../../b"} { + if err := validateAppDevDir(bad); err == nil { + t.Errorf("%q should be rejected", bad) + } + } +} + +func TestEnsureAppDevDirUsable(t *testing.T) { + dir := t.TempDir() + if err := ensureAppDevDirUsable(filepath.Join(dir, "missing")); err != nil { + t.Errorf("missing dir should be usable: %v", err) + } + empty := filepath.Join(dir, "empty") + if err := os.Mkdir(empty, 0o755); err != nil { + t.Fatal(err) + } + if err := ensureAppDevDirUsable(empty); err != nil { + t.Errorf("empty dir should be usable: %v", err) + } + nonEmpty := filepath.Join(dir, "full") + if err := os.Mkdir(nonEmpty, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nonEmpty, "x.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + err := ensureAppDevDirUsable(nonEmpty) + if err == nil { + t.Fatal("non-empty dir must be rejected") + } + p, ok := errs.ProblemOf(err) + if !ok || p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("want failed_precondition, got %v", err) + } + if !strings.Contains(p.Message, "already exists and is not empty") { + t.Errorf("message = %q", p.Message) + } +} + +func TestReadMetaStack(t *testing.T) { + dir := t.TempDir() + if s, ok, err := readMetaStack(dir); s != "" || ok || err != nil { + t.Errorf("missing meta: got (%q,%v,%v)", s, ok, err) + } + if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"stack":"react-standard-webapp","version":"1.0.0"}`), 0o644); err != nil { + t.Fatal(err) + } + s, ok, err := readMetaStack(dir) + if err != nil || !ok || s != "react-standard-webapp" { + t.Errorf("got (%q,%v,%v)", s, ok, err) + } +} + +// --- declaration & validate tests --- + +func TestAppsAppDevInitApp_Declaration(t *testing.T) { + if AppsAppDevInitApp.Command != "+app-dev-init-app" { + t.Errorf("Command = %q", AppsAppDevInitApp.Command) + } + if AppsAppDevInitApp.Service != appsService { + t.Errorf("Service = %q", AppsAppDevInitApp.Service) + } + if AppsAppDevInitApp.Risk != "write" { + t.Errorf("Risk = %q, want write", AppsAppDevInitApp.Risk) + } + if !AppsAppDevInitApp.HasFormat { + t.Error("HasFormat = false, want true") + } + if AppsAppDevInitApp.Scopes == nil { + t.Error("Scopes must be non-nil (no remote API => empty slice)") + } +} + +// testRuntimeAppDevInit builds a RuntimeContext with the type/dir flags +// registered, mirroring how the shortcut reads them via rctx.Str. +func testRuntimeAppDevInit(t *testing.T, appType, dir string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "+app-dev-init-app"} + cmd.Flags().String("type", appType, "") + cmd.Flags().String("dir", dir, "") + return common.TestNewRuntimeContext(cmd, nil) +} + +func TestAppDevInitAppValidate(t *testing.T) { + tests := []struct { + name, appType, dir, wantErr string + }{ + {"missing type", "", "", "--type is required"}, + {"abs dir", "frontend", "/abs", "--dir"}, + {"dotdot dir", "frontend", "../x", "--dir"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := AppsAppDevInitApp.Validate(context.Background(), testRuntimeAppDevInit(t, tt.appType, tt.dir)) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("err = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestAppDevInitAppValidate_NpxMissing(t *testing.T) { + orig := appDevLookPath + appDevLookPath = func(string) (string, error) { return "", errors.New("not found") } + t.Cleanup(func() { appDevLookPath = orig }) + err := AppsAppDevInitApp.Validate(context.Background(), testRuntimeAppDevInit(t, "frontend", "")) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", p.Subtype) + } + if !strings.Contains(p.Hint, "Node.js") { + t.Errorf("hint = %q, want Node.js install guidance", p.Hint) + } +} + +// --- execute tests (framework runner + fake commandRunner) --- + +// relAppDevDir returns a relative, cwd-contained, not-yet-existing directory +// suitable for --dir (mirrors relCloneDir). +func relAppDevDir(t *testing.T) string { + t.Helper() + rel := "app-dev-" + strings.ReplaceAll(t.Name(), "/", "_") + t.Cleanup(func() { os.RemoveAll(rel) }) + return rel +} + +func TestAppDevInitAppExecute_DelegatesNpx(t *testing.T) { + f := &fakeCommandRunner{} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relAppDevDir(t) + if err := runAppsShortcut(t, AppsAppDevInitApp, + []string{"+app-dev-init-app", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + c := findCall(f.calls, "npx", "-y") + if c == nil { + t.Fatalf("npx not invoked: %v", f.calls) + } + if !containsAll(c, "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, + "app", "init", "--template", "react-standard-webapp", "--skip-install") { + t.Errorf("npx args = %v", c) + } + if containsAll(c, "--app-id") { + t.Errorf("app init must NOT carry --app-id in artifact-hosting mode: %v", c) + } + if c[0] != dir { + t.Errorf("npx cwd = %q, want %q", c[0], dir) + } + data := parseEnvelopeData(t, stdout) + if data["dir"] != dir || data["template"] != "react-standard-webapp" { + t.Errorf("data = %v", data) + } + if data["stack"] != "react-standard-webapp" { + t.Errorf("stack fallback = %v, want template name", data["stack"]) + } + steps, _ := data["next_steps"].([]interface{}) + if len(steps) != 3 { + t.Errorf("next_steps = %v, want 3 entries", data["next_steps"]) + } +} + +func TestAppDevInitAppExecute_FullStackTemplate(t *testing.T) { + f := &fakeCommandRunner{} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relAppDevDir(t) + if err := runAppsShortcut(t, AppsAppDevInitApp, + []string{"+app-dev-init-app", "--type", "full_stack", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + if c := findCall(f.calls, "npx", "-y"); c == nil || !containsAll(c, "--template", "react-express-standard-fullstack") { + t.Errorf("full_stack template not passed: %v", f.calls) + } +} + +func TestAppDevInitAppExecute_MetaStackEcho(t *testing.T) { + dir := relAppDevDir(t) + f := &fakeCommandRunner{results: map[string]fakeCallResult{}} + // Simulate the template producing .spark/meta.json during scaffold. + f.results["npx -y"] = fakeCallResult{} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + // Pre-create meta.json via a side channel: the fake runner records but + // does not write files, so write it before Execute reads it back — the + // dir must stay empty for ensureAppDevDirUsable, so use a wrapper runner. + wrapped := &metaWritingRunner{inner: f, dir: dir, stack: "custom-stack"} + initRunner = wrapped + if err := runAppsShortcut(t, AppsAppDevInitApp, + []string{"+app-dev-init-app", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["stack"] != "custom-stack" { + t.Errorf("stack = %v, want custom-stack (from meta.json)", data["stack"]) + } +} + +// metaWritingRunner simulates miaoda-cli writing .spark/meta.json into the +// scaffold dir as a side effect of app init. +type metaWritingRunner struct { + inner *fakeCommandRunner + dir string + stack string +} + +func (m *metaWritingRunner) Run(ctx context.Context, dir, name string, args ...string) (string, string, error) { + if err := os.MkdirAll(filepath.Join(m.dir, ".spark"), 0o755); err != nil { + return "", "", err + } + if err := os.WriteFile(filepath.Join(m.dir, metaRelPath), []byte(`{"stack":"`+m.stack+`"}`), 0o644); err != nil { + return "", "", err + } + return m.inner.Run(ctx, dir, name, args...) +} + +func TestAppDevInitAppExecute_NpxFails(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "npx -y": {stderr: "boom", err: errors.New("exit 1")}, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relAppDevDir(t) + err := runAppsShortcut(t, AppsAppDevInitApp, + []string{"+app-dev-init-app", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryInternal) + if !strings.Contains(p.Message, "npx app init failed") || !strings.Contains(p.Message, "boom") { + t.Errorf("message = %q", p.Message) + } +} + +func TestAppDevInitAppExecute_DirNotEmpty(t *testing.T) { + dir := relAppDevDir(t) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "x.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + f := &fakeCommandRunner{} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAppDevInitApp, + []string{"+app-dev-init-app", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q", p.Subtype) + } + if len(f.calls) != 0 { + t.Errorf("npx must not run when dir is not empty: %v", f.calls) + } +} + +func TestAppDevInitAppDryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsAppDevInitApp, + []string{"+app-dev-init-app", "--type", "frontend", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + data, err := decodeDryRunDataMap(stdout.Bytes()) + if err != nil { + t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String()) + } + cmdLine, _ := data["command"].(string) + if !strings.Contains(cmdLine, "app init --template react-standard-webapp --skip-install") { + t.Errorf("command = %q", cmdLine) + } + if data["remote_side_effects"] != "none (local scaffold via npx)" { + t.Errorf("remote_side_effects = %v", data["remote_side_effects"]) + } + if data["target_dir"] != filepath.Join(".", "react-standard-webapp") { + t.Errorf("target_dir = %v", data["target_dir"]) + } +} From 0bb08e7cabd41ec85adb62d1dbcf3d457fca14c8 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 10:23:08 +0800 Subject: [PATCH 02/51] refactor(apps): extract parsePreReleaseKVs helper --- shortcuts/apps/apps_html_publish.go | 16 ++----------- shortcuts/apps/apps_release_common.go | 19 +++++++++++++++ shortcuts/apps/apps_release_common_test.go | 27 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 shortcuts/apps/apps_release_common_test.go diff --git a/shortcuts/apps/apps_html_publish.go b/shortcuts/apps/apps_html_publish.go index c1b4a3904a..3df79daf97 100644 --- a/shortcuts/apps/apps_html_publish.go +++ b/shortcuts/apps/apps_html_publish.go @@ -331,22 +331,10 @@ func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec ap if err != nil { return nil, err } - kvs, _ := preData["kvs"].([]interface{}) - if len(kvs) == 0 { + kvm := parsePreReleaseKVs(preData) + if len(kvm) == 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 == "" { diff --git a/shortcuts/apps/apps_release_common.go b/shortcuts/apps/apps_release_common.go index 694a82d1ae..e2aa7cd5e3 100644 --- a/shortcuts/apps/apps_release_common.go +++ b/shortcuts/apps/apps_release_common.go @@ -18,6 +18,25 @@ const ( releaseListPath = apiBasePath + "/apps/%s/releases" ) +// parsePreReleaseKVs flattens a pre_release response's kvs array into a +// key->value map. Entries without a string key are skipped. +func parsePreReleaseKVs(data map[string]interface{}) map[string]string { + kvs, _ := data["kvs"].([]interface{}) + 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 + } + } + return kvm +} + // writeReleaseErrorLogTable renders a release's error_logs (a slice of // {step, error_log} maps from the gateway) as a two-column step/error_log // table via output.PrintTable. Used by +release-get to render a failed diff --git a/shortcuts/apps/apps_release_common_test.go b/shortcuts/apps/apps_release_common_test.go new file mode 100644 index 0000000000..153d0001df --- /dev/null +++ b/shortcuts/apps/apps_release_common_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import "testing" + +func TestParsePreReleaseKVs(t *testing.T) { + data := map[string]interface{}{ + "kvs": []interface{}{ + map[string]interface{}{"key": "upload_url", "value": "https://tos/put"}, + map[string]interface{}{"key": "MIAODA_CLIENT_BASE_PATH", "value": "/app/x"}, + map[string]interface{}{"key": "", "value": "ignored"}, + "not-a-map", + }, + } + kvm := parsePreReleaseKVs(data) + if kvm["upload_url"] != "https://tos/put" || kvm["MIAODA_CLIENT_BASE_PATH"] != "/app/x" { + t.Errorf("unexpected kvm: %v", kvm) + } + if len(kvm) != 2 { + t.Errorf("len = %d, want 2 (empty key and non-map entries skipped)", len(kvm)) + } + if len(parsePreReleaseKVs(map[string]interface{}{})) != 0 { + t.Error("empty data should yield empty map") + } +} From d52cdaf2f5c188a5375b9d70729fd2bfdc5e70d7 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 10:27:36 +0800 Subject: [PATCH 03/51] feat(apps): add +app-dev-publish shortcut --- shortcuts/apps/app_dev_publish_zip.go | 73 +++ shortcuts/apps/app_dev_publish_zip_test.go | 24 + shortcuts/apps/apps_app_dev_publish.go | 370 ++++++++++++++ shortcuts/apps/apps_app_dev_publish_test.go | 535 ++++++++++++++++++++ 4 files changed, 1002 insertions(+) create mode 100644 shortcuts/apps/app_dev_publish_zip.go create mode 100644 shortcuts/apps/app_dev_publish_zip_test.go create mode 100644 shortcuts/apps/apps_app_dev_publish.go create mode 100644 shortcuts/apps/apps_app_dev_publish_test.go diff --git a/shortcuts/apps/app_dev_publish_zip.go b/shortcuts/apps/app_dev_publish_zip.go new file mode 100644 index 0000000000..c80fc963b6 --- /dev/null +++ b/shortcuts/apps/app_dev_publish_zip.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/zip" + "bytes" + "io" + + "github.com/larksuite/cli/extension/fileio" +) + +// Size caps for the app-dev publish payload. Defaults pending server-side +// confirmation; vars (not consts) so unit tests can shrink them to cover the +// rejection paths. +var ( + // maxAppDevPublishRawBytes caps total uncompressed input, defending + // against decompression-bomb style inputs before they balloon memory. + maxAppDevPublishRawBytes int64 = 200 * 1024 * 1024 + // maxAppDevPublishZipBytes caps the packed zip payload. + maxAppDevPublishZipBytes int64 = 50 * 1024 * 1024 +) + +// appDevZipball is an in-memory zip payload ready for TOS upload. +type appDevZipball struct { + Body []byte + Size int64 + FileCount int +} + +// buildAppDevZip packs candidates (paths relative to the dist dir, e.g. +// "output/index.html") into an in-memory zip. Entry names keep the +// output/... layout and never include the dist directory itself. +func buildAppDevZip(fio fileio.FileIO, candidates []htmlPublishCandidate) (*appDevZipball, error) { + var rawTotal int64 + for _, c := range candidates { + rawTotal += c.Size + } + if rawTotal > maxAppDevPublishRawBytes { + return nil, appsValidationError( + "dist total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", + rawTotal, maxAppDevPublishRawBytes). + WithHint("reduce dist contents before publishing") + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, c := range candidates { + w, err := zw.Create(c.RelPath) + if err != nil { + return nil, appsFileIOError(err, "zip create %s failed: %v", c.RelPath, err) + } + f, err := fio.Open(c.AbsPath) + if err != nil { + return nil, appsInputPathEntryError(c.AbsPath, err) + } + _, err = io.Copy(w, f) + f.Close() + if err != nil { + return nil, appsFileIOError(err, "zip write %s failed: %v", c.RelPath, err) + } + } + if err := zw.Close(); err != nil { + return nil, appsFileIOError(err, "zip finalize failed: %v", err) + } + size := int64(buf.Len()) + if size > maxAppDevPublishZipBytes { + return nil, appsValidationError( + "packed zip size %d bytes exceeds %d bytes limit", size, maxAppDevPublishZipBytes). + WithHint("reduce dist contents; large media should be served from external storage") + } + return &appDevZipball{Body: buf.Bytes(), Size: size, FileCount: len(candidates)}, nil +} diff --git a/shortcuts/apps/app_dev_publish_zip_test.go b/shortcuts/apps/app_dev_publish_zip_test.go new file mode 100644 index 0000000000..28f01d9151 --- /dev/null +++ b/shortcuts/apps/app_dev_publish_zip_test.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/zip" + "bytes" + "testing" +) + +// zipEntryNames opens an in-memory zip and returns its entry names. +func zipEntryNames(t *testing.T, body []byte) []string { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + t.Fatalf("open zip: %v", err) + } + names := make([]string, 0, len(zr.File)) + for _, f := range zr.File { + names = append(names, f.Name) + } + return names +} diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go new file mode 100644 index 0000000000..b5ed7421c2 --- /dev/null +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -0,0 +1,370 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// appDevDistDir is the fixed build output directory of the artifact-hosting +// layout; +app-dev-publish always publishes ./dist from the project root. +const appDevDistDir = "dist" + +// appDevEnvPrefix is the allowlist prefix for build env vars handed down by +// pre_release. Only exact, case-sensitive MIAODA_* keys are injected into the +// build subprocess — this is the security boundary that keeps a compromised +// server response from smuggling NODE_OPTIONS / PATH / LD_PRELOAD into a +// local process. +const appDevEnvPrefix = "MIAODA_" + +// appDevBuildEnv filters pre_release kvs down to injectable build env vars. +// Returns KEY=VALUE entries plus the injected key names (sorted, for the +// audit line on stderr). Keys containing '=', NUL, CR or LF are dropped. +func appDevBuildEnv(kvm map[string]string) (env []string, keys []string) { + for k := range kvm { + if !strings.HasPrefix(k, appDevEnvPrefix) { + continue + } + if strings.ContainsAny(k, "=\x00\n\r") { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + env = append(env, k+"="+kvm[k]) + } + return env, keys +} + +// ensureMetaOnlineURL merge-writes online_url into /.spark/meta.json, +// preserving existing fields. A missing file is not an error — the backfill +// is best-effort. +func ensureMetaOnlineURL(dir, onlineURL string) error { + path := filepath.Join(dir, metaRelPath) + b, err := os.ReadFile(path) //nolint:forbidigo // same rationale as readMetaAppID + if err != nil { + if os.IsNotExist(err) { + return nil + } + return appsFileIOError(err, "read %s failed: %v", metaRelPath, err) + } + var meta map[string]interface{} + if err := json.Unmarshal(b, &meta); err != nil { + return appsFileIOError(err, "parse %s failed: %v", metaRelPath, err) + } + meta["online_url"] = onlineURL + out, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err) + } + if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // same rationale + return appsFileIOError(err, "write %s failed: %v", metaRelPath, err) + } + return nil +} + +// validateAppDevDist walks the dist directory and enforces the +// artifact-hosting layout: output/{index.html,routes.json} required, +// output_resource/ optional, nothing else at the top level. Returns the +// candidates for zip packing. allowSensitive skips the credential-file scan. +func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) ([]htmlPublishCandidate, error) { + candidates, err := walkHTMLPublishCandidates(fio, distPath) + if err != nil { + // A missing dist directory means "build first", not a bad flag value. + if errors.Is(err, fs.ErrNotExist) { + return nil, appsFailedPreconditionError( + "dist directory not found; the artifact-hosting layout expects ./dist"). + WithHint("run npm run build first, or drop --skip-build to let the command build") + } + return nil, err + } + var hasIndex, hasRoutes, hasOutput bool + var extras []string + seenExtras := map[string]bool{} + for _, c := range candidates { + top := c.RelPath + if i := strings.IndexByte(top, '/'); i >= 0 { + top = top[:i] + } + switch top { + case "output": + hasOutput = true + switch c.RelPath { + case "output/index.html": + hasIndex = true + case "output/routes.json": + hasRoutes = true + } + case "output_resource": + default: + if !seenExtras[top] { + seenExtras[top] = true + extras = append(extras, top) + } + } + } + if len(extras) > 0 { + sort.Strings(extras) + return nil, appsValidationError( + "dist contains %d top-level entr(ies) outside the artifact-hosting layout: %s", + len(extras), truncatedJoin(extras, maxSensitiveListInError)). + WithHint("only output/ and output_resource/ are uploaded; adjust the build output") + } + if !hasOutput { + return nil, appsFailedPreconditionError( + "dist is missing the output/ directory required by the artifact-hosting layout"). + WithHint("build first (npm run build); expected layout: dist/output/{index.html,routes.json} + dist/output_resource/") + } + if !hasIndex { + return nil, appsFailedPreconditionError("dist/output is missing index.html"). + WithHint("output/index.html is the app entrypoint; check the template's build config") + } + if !hasRoutes { + return nil, appsFailedPreconditionError("dist/output is missing routes.json"). + WithHint("routes.json is required for content review routing; miaoda-cli templates generate it during npm run build") + } + if !allowSensitive { + var hits []string + for _, c := range candidates { + if isSensitiveCandidate(distPath, c) { + hits = append(hits, c.RelPath) + } + } + if len(hits) > 0 { + return nil, sensitiveCandidatesError(hits) + } + } + return candidates, nil +} + +// envCommandRunner runs a subprocess with extra environment variables +// appended to the parent env. Separate from commandRunner because only the +// build step needs env injection, and a dedicated seam keeps init tests and +// publish tests from fighting over one package-level fake. +type envCommandRunner interface { + RunEnv(ctx context.Context, dir string, extraEnv []string, name string, args ...string) (stdout, stderr string, err error) +} + +type execEnvCommandRunner struct{} + +func (execEnvCommandRunner) RunEnv(ctx context.Context, dir string, extraEnv []string, name string, args ...string) (string, string, error) { + cmd := exec.CommandContext(ctx, name, args...) + if dir != "" { + cmd.Dir = dir + } + cmd.Env = append(os.Environ(), extraEnv...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +// appDevRunner is the envCommandRunner used by +app-dev-publish's build step. +// Package-level so unit tests can swap in a fake. +var appDevRunner envCommandRunner = execEnvCommandRunner{} + +// appDevNewTransferClient builds the HTTP client for the presigned TOS +// upload. Package-level so unit tests can inject an httptest TLS client +// (the command only accepts https upload URLs). +var appDevNewTransferClient = newFileTransferClient + +// AppsAppDevPublish builds and publishes a local web app project to its +// Miaoda app. Run from the project root containing .spark/meta.json. +var AppsAppDevPublish = common.Shortcut{ + Service: appsService, + Command: "+app-dev-publish", + Description: "Build and publish a local web app project to its Miaoda app (run from the project root containing .spark/meta.json)", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +app-dev-publish (run from the project root)", + "Example: lark-cli apps +app-dev-publish --skip-build (reuse an existing ./dist)", + "Prerequisite: .spark/meta.json must contain app_id (create the app with +create first)", + }, + Scopes: []string{"spark:app:write", "spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "skip-build", Type: "bool", Desc: "skip npm run build and publish the existing ./dist as-is"}, + {Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / etc. in the publish payload)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, isSpark, err := readMetaAppID(".") + if err != nil { + return err + } + if !isSpark { + return appsFailedPreconditionError( + "current directory is not a Miaoda app project (.spark/meta.json not found)"). + WithHint("run this command from the project root; scaffold a project with +app-dev-init-app first") + } + if strings.TrimSpace(appID) == "" { + return appsFailedPreconditionError(".spark/meta.json has no app_id"). + WithHint("create the app with `lark-cli apps +create --name `, then write the returned app_id into .spark/meta.json") + } + if err := validateRealAppID(appID); err != nil { + return err + } + if rctx.Bool("skip-build") { + if _, err := rctx.FileIO().Stat(appDevDistDir); err != nil { + return appsFailedPreconditionError("--skip-build is set but ./dist does not exist"). + WithHint("run npm run build first, or drop --skip-build to let the command build") + } + } else if _, err := appDevLookPath("npm"); err != nil { + return appsFailedPreconditionError("npm executable not found on PATH"). + WithHint("install Node.js (which provides npm), or build manually and retry with --skip-build") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + dry := common.NewDryRunAPI(). + Desc("Read .spark/meta.json app_id -> GET pre_release (upload_url/tos_path + MIAODA_* build env) -> npm run build -> validate dist layout -> zip -> PUT to TOS -> POST releases; returns online_url (sync) or release_id (async)") + appID, isSpark, err := readMetaAppID(".") + switch { + case err != nil: + dry.Set("meta_error", err.Error()) + case !isSpark: + dry.Set("meta_error", ".spark/meta.json not found in current directory") + default: + dry.Set("app_id", appID) + dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))). + PUT(" (https only, from pre_release kvs)"). + POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))). + Body(map[string]string{"tos_path": ""}) + } + dry.Set("build_command", "npm run build (env allowlist: MIAODA_* keys from pre_release; skipped with --skip-build)") + if candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), appDevDistDir); err != nil { + dry.Set("dist_state", "missing or unreadable: "+err.Error()) + } else { + dry.Set("dist_file_count", len(candidates)) + if _, verr := validateAppDevDist(rctx.FileIO(), appDevDistDir, rctx.Bool("allow-sensitive")); verr != nil { + dry.Set("dist_validation_error", verr.Error()) + } + } + return dry + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _, err := readMetaAppID(".") + if err != nil { + return err + } + // meta.json is a tamperable workspace file and the server-side owner + // check is the only authorization line — echo the target loudly so a + // wrong app_id is visible before anything ships. + fmt.Fprintf(rctx.IO().ErrOut, "publishing to app %s (from %s)\n", appID, metaRelPath) + + // pre_release comes before the build: no point building when the app + // is missing or inaccessible, and the build env rides on this response. + preReleasePath := fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID)) + preData, err := rctx.CallAPITyped("GET", preReleasePath, nil, nil) + if err != nil { + return withAppsHint(err, appIDListHint) + } + kvm := parsePreReleaseKVs(preData) + uploadURL, tosPath := kvm["upload_url"], kvm["tos_path"] + if uploadURL == "" || tosPath == "" { + return appsSubprocessEnvelopeError("pre_release kvs missing upload_url or tos_path") + } + if u, perr := url.Parse(uploadURL); perr != nil || u.Scheme != "https" { + return appsSubprocessEnvelopeError("pre_release upload_url is not https; refusing to upload") + } + + built := false + if !rctx.Bool("skip-build") { + env, keys := appDevBuildEnv(kvm) + if len(keys) > 0 { + fmt.Fprintf(rctx.IO().ErrOut, "injecting build env: %s\n", strings.Join(keys, ", ")) + } + fmt.Fprintln(rctx.IO().ErrOut, "running npm run build...") + if _, stderr, err := appDevRunner.RunEnv(ctx, "", env, "npm", "run", "build"); err != nil { + return appsExternalToolError(err, "npm run build failed: %s", gitErr(stderr, err)). + WithHint("fix the build errors and retry; or build manually and retry with --skip-build") + } + built = true + } + + candidates, err := validateAppDevDist(rctx.FileIO(), appDevDistDir, rctx.Bool("allow-sensitive")) + if err != nil { + return err + } + zipball, err := buildAppDevZip(rctx.FileIO(), candidates) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(zipball.Body)) + if err != nil { + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "build TOS upload request").WithCause(err) + } + req.ContentLength = zipball.Size + req.Header.Set("Content-Type", "application/zip") + resp, err := appDevNewTransferClient().Do(req) //nolint:forbidigo // presigned TOS upload bypasses the Lark gateway (same as +html-publish) + if err != nil { + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed").WithCause(err).WithRetryable() + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + if resp.StatusCode >= 500 { + return errs.NewNetworkError(errs.SubtypeNetworkServer, "TOS upload failed: HTTP %d", resp.StatusCode).WithRetryable() + } + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed: HTTP %d", resp.StatusCode) + } + + releasePath := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID)) + releaseData, err := rctx.CallAPITyped("POST", releasePath, nil, map[string]interface{}{"tos_path": tosPath}) + if err != nil { + return withAppsHint(err, "verify the app supports artifact-hosting publish; list your apps with `lark-cli apps +list`") + } + + releaseID := common.GetString(releaseData, "release_id") + status := common.GetString(releaseData, "status") + onlineURL := common.GetString(releaseData, "online_url") + data := map[string]interface{}{ + "app_id": appID, + "release_id": releaseID, + "status": status, + "built": built, + "file_count": zipball.FileCount, + "zip_size_bytes": zipball.Size, + } + pollHint := "" + if onlineURL != "" { + data["online_url"] = onlineURL + if err := ensureMetaOnlineURL(".", onlineURL); err != nil { + fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to backfill online_url into %s: %v\n", metaRelPath, err) + } + } else { + pollHint = fmt.Sprintf("lark-cli apps +release-get --app-id %s --release-id %s", appID, releaseID) + data["poll_hint"] = pollHint + } + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "app_id: %s\nrelease_id: %s\nstatus: %s\n", appID, releaseID, status) + if onlineURL != "" { + fmt.Fprintf(w, "online_url: %s\n", onlineURL) + } else { + fmt.Fprintf(w, "async release; poll with: %s\n", pollHint) + } + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go new file mode 100644 index 0000000000..aa059fc4fe --- /dev/null +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -0,0 +1,535 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +// --- pure-function tests --- + +func TestAppDevBuildEnv(t *testing.T) { + kvm := map[string]string{ + "upload_url": "https://tos/put", + "tos_path": "x/y.zip", + "MIAODA_CLIENT_BASE_PATH": "/app/x", + "MIAODA_RESOURCE_CDN_PREFIX": "https://lf.example", + "miaoda_lowercase": "must-not-inject", + "NODE_OPTIONS": "--require evil", + "MIAODA_BAD=KEY": "reject-equals", + "MIAODA_BAD\nKEY": "reject-newline", + "MIAODA_BAD\rKEY": "reject-cr", + } + env, keys := appDevBuildEnv(kvm) + wantEnv := []string{ + "MIAODA_CLIENT_BASE_PATH=/app/x", + "MIAODA_RESOURCE_CDN_PREFIX=https://lf.example", + } + wantKeys := []string{"MIAODA_CLIENT_BASE_PATH", "MIAODA_RESOURCE_CDN_PREFIX"} + if !reflect.DeepEqual(env, wantEnv) || !reflect.DeepEqual(keys, wantKeys) { + t.Errorf("appDevBuildEnv = (%v, %v), want (%v, %v)", env, keys, wantEnv, wantKeys) + } + if env, keys := appDevBuildEnv(nil); len(env) != 0 || len(keys) != 0 { + t.Errorf("nil kvm should yield empty results, got (%v, %v)", env, keys) + } +} + +func TestEnsureMetaOnlineURL(t *testing.T) { + dir := t.TempDir() + // Missing meta.json: best-effort no-op. + if err := ensureMetaOnlineURL(dir, "https://x/app/app_x"); err != nil { + t.Errorf("missing meta must not error: %v", err) + } + if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x","stack":"s"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := ensureMetaOnlineURL(dir, "https://x/app/app_x"); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) + if err != nil { + t.Fatal(err) + } + var meta map[string]interface{} + if err := json.Unmarshal(b, &meta); err != nil { + t.Fatal(err) + } + if meta["online_url"] != "https://x/app/app_x" || meta["app_id"] != "app_x" || meta["stack"] != "s" { + t.Errorf("meta after backfill = %v", meta) + } +} + +// --- dist layout validation --- + +// writeDistFiles creates files (relative to base) with parent dirs. +func writeDistFiles(t *testing.T, base string, files []string) { + t.Helper() + for _, f := range files { + p := filepath.Join(base, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestValidateAppDevDist(t *testing.T) { + tests := []struct { + name string + files []string + wantErr string // "" = valid + }{ + {"ok full", []string{"output/index.html", "output/routes.json", "output_resource/index.js"}, ""}, + {"ok no resource", []string{"output/index.html", "output/routes.json"}, ""}, + {"no output dir", []string{"stray.txt"}, "top-level entr"}, + {"no index", []string{"output/routes.json"}, "index.html"}, + {"no routes", []string{"output/index.html"}, "routes.json"}, + {"extra top-level dir", []string{"output/index.html", "output/routes.json", "extra/x.js"}, "outside the artifact-hosting layout"}, + {"extra top-level file", []string{"output/index.html", "output/routes.json", "notes.md"}, "outside the artifact-hosting layout"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dist := filepath.Join(t.TempDir(), "dist") + writeDistFiles(t, dist, tt.files) + _, err := validateAppDevDist(permissiveFIO{}, dist, false) + if tt.wantErr == "" { + if err != nil { + t.Errorf("want valid, got %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("err = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestValidateAppDevDist_Missing(t *testing.T) { + _, err := validateAppDevDist(permissiveFIO{}, filepath.Join(t.TempDir(), "dist"), false) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", p.Subtype) + } + if !strings.Contains(p.Hint, "--skip-build") { + t.Errorf("hint = %q", p.Hint) + } +} + +func TestValidateAppDevDist_Sensitive(t *testing.T) { + dist := filepath.Join(t.TempDir(), "dist") + writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json", "output/.env"}) + _, err := validateAppDevDist(permissiveFIO{}, dist, false) + if err == nil || !strings.Contains(err.Error(), "credential file") { + t.Errorf("sensitive file must be rejected, got %v", err) + } + if _, err := validateAppDevDist(permissiveFIO{}, dist, true); err != nil { + t.Errorf("allow-sensitive must waive the scan: %v", err) + } +} + +// --- zip packing --- + +func TestBuildAppDevZip(t *testing.T) { + dist := filepath.Join(t.TempDir(), "dist") + writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json", "output_resource/a.js"}) + candidates, err := validateAppDevDist(permissiveFIO{}, dist, false) + if err != nil { + t.Fatal(err) + } + zipball, err := buildAppDevZip(permissiveFIO{}, candidates) + if err != nil { + t.Fatal(err) + } + if zipball.FileCount != 3 || zipball.Size != int64(len(zipball.Body)) { + t.Errorf("FileCount=%d Size=%d len(Body)=%d", zipball.FileCount, zipball.Size, len(zipball.Body)) + } + names := zipEntryNames(t, zipball.Body) + want := map[string]bool{"output/index.html": true, "output/routes.json": true, "output_resource/a.js": true} + if len(names) != len(want) { + t.Fatalf("entries = %v", names) + } + for _, n := range names { + if !want[n] { + t.Errorf("unexpected zip entry %q (dist prefix must be stripped)", n) + } + } +} + +func TestBuildAppDevZip_RawSizeCap(t *testing.T) { + orig := maxAppDevPublishRawBytes + maxAppDevPublishRawBytes = 1 + t.Cleanup(func() { maxAppDevPublishRawBytes = orig }) + dist := filepath.Join(t.TempDir(), "dist") + writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json"}) + candidates, err := validateAppDevDist(permissiveFIO{}, dist, false) + if err != nil { + t.Fatal(err) + } + if _, err := buildAppDevZip(permissiveFIO{}, candidates); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Errorf("raw cap must reject, got %v", err) + } +} + +func TestBuildAppDevZip_ZipSizeCap(t *testing.T) { + orig := maxAppDevPublishZipBytes + maxAppDevPublishZipBytes = 1 + t.Cleanup(func() { maxAppDevPublishZipBytes = orig }) + dist := filepath.Join(t.TempDir(), "dist") + writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json"}) + candidates, err := validateAppDevDist(permissiveFIO{}, dist, false) + if err != nil { + t.Fatal(err) + } + _, err = buildAppDevZip(permissiveFIO{}, candidates) + if err == nil || !strings.Contains(err.Error(), "packed zip size") { + t.Errorf("zip cap must reject, got %v", err) + } + p, _ := errs.ProblemOf(err) + if p == nil || !strings.Contains(p.Hint, "reduce dist contents") { + t.Errorf("hint = %v", p) + } +} + +// --- shortcut orchestration --- + +// fakeEnvRunner records the build invocation and optionally materializes dist +// as a side effect (simulating npm run build). +type fakeEnvRunner struct { + called bool + dir, name string + args, env []string + stderr string + err error + sideEffect func() +} + +func (f *fakeEnvRunner) RunEnv(ctx context.Context, dir string, extraEnv []string, name string, args ...string) (string, string, error) { + f.called = true + f.dir, f.name, f.args, f.env = dir, name, args, extraEnv + if f.sideEffect != nil { + f.sideEffect() + } + return "", f.stderr, f.err +} + +func withFakeEnvRunner(t *testing.T, f *fakeEnvRunner) { + t.Helper() + orig := appDevRunner + appDevRunner = f + t.Cleanup(func() { appDevRunner = orig }) +} + +// chdirProjectRoot creates a temp project root with .spark/meta.json and +// chdirs into it for the test (the shortcut reads meta.json from cwd). +func chdirProjectRoot(t *testing.T, metaJSON string) string { + t.Helper() + root := t.TempDir() + if metaJSON != "" { + if err := os.MkdirAll(filepath.Join(root, ".spark"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, metaRelPath), []byte(metaJSON), 0o644); err != nil { + t.Fatal(err) + } + } + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(old) }) + return root +} + +// newTOSTLSServer starts a TLS server for the presigned PUT and swaps +// appDevNewTransferClient to trust its certificate. +func newTOSTLSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + srv := httptest.NewTLSServer(handler) + t.Cleanup(srv.Close) + orig := appDevNewTransferClient + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevNewTransferClient = orig }) + return srv +} + +func stubPreRelease(reg *httpmock.Registry, appID, uploadURL string, extraKVs map[string]string) { + kvs := []interface{}{ + map[string]interface{}{"key": "upload_url", "value": uploadURL}, + map[string]interface{}{"key": "tos_path", "value": "bucket/pkg.zip"}, + } + for k, v := range extraKVs { + kvs = append(kvs, map[string]interface{}{"key": k, "value": v}) + } + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/" + appID + "/pre_release", + Body: map[string]interface{}{ + "code": float64(0), + "data": map[string]interface{}{"kvs": kvs}, + }, + }) +} + +func stubReleases(reg *httpmock.Registry, appID string, respData map[string]interface{}) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/" + appID + "/releases", + Body: map[string]interface{}{ + "code": float64(0), + "data": respData, + }, + }) +} + +func TestAppDevPublishValidate_NoMeta(t *testing.T) { + chdirProjectRoot(t, "") + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "not a Miaoda app project") { + t.Errorf("got %v", p) + } + if !strings.Contains(p.Hint, "+app-dev-init-app") { + t.Errorf("hint = %q", p.Hint) + } +} + +func TestAppDevPublishValidate_NoAppID(t *testing.T) { + chdirProjectRoot(t, `{"stack":"react-standard-webapp"}`) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if !strings.Contains(p.Message, "no app_id") || !strings.Contains(p.Hint, "+create") { + t.Errorf("got %v", p) + } +} + +func TestAppDevPublishValidate_BadAppID(t *testing.T) { + chdirProjectRoot(t, `{"app_id":"meta_token_x"}`) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if !strings.Contains(p.Message, "app_") { + t.Errorf("got %v", p) + } +} + +func TestAppDevPublishValidate_SkipBuildNoDist(t *testing.T) { + chdirProjectRoot(t, `{"app_id":"app_x"}`) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "./dist does not exist") { + t.Errorf("got %v", p) + } +} + +func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { + root := chdirProjectRoot(t, `{"app_id":"app_x","stack":"react-standard-webapp"}`) + var uploaded []byte + var contentType string + srv := newTOSTLSServer(t, func(w http.ResponseWriter, r *http.Request) { + contentType = r.Header.Get("Content-Type") + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + uploaded = buf + w.WriteHeader(200) + }) + f := &fakeEnvRunner{sideEffect: func() { + writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json", "output_resource/a.js"}) + }} + withFakeEnvRunner(t, f) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, map[string]string{ + "MIAODA_CLIENT_BASE_PATH": "/app/app_x", + "NODE_OPTIONS": "--require evil", + }) + stubReleases(reg, "app_x", map[string]interface{}{ + "release_id": "rel_1", "status": "finished", + "online_url": "https://x.feishuapp.cn/app/app_x", + }) + if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + // Build invocation contract. + if !f.called || f.name != "npm" || !reflect.DeepEqual(f.args, []string{"run", "build"}) { + t.Errorf("build call = %v %v (called=%v)", f.name, f.args, f.called) + } + if !reflect.DeepEqual(f.env, []string{"MIAODA_CLIENT_BASE_PATH=/app/app_x"}) { + t.Errorf("injected env = %v (NODE_OPTIONS must be filtered)", f.env) + } + // Upload contract. + if contentType != "application/zip" { + t.Errorf("Content-Type = %q", contentType) + } + if len(uploaded) == 0 { + t.Error("zip body not uploaded") + } + // Output contract. + data := parseEnvelopeData(t, stdout) + if data["online_url"] != "https://x.feishuapp.cn/app/app_x" || data["release_id"] != "rel_1" { + t.Errorf("data = %v", data) + } + if data["built"] != true { + t.Errorf("built = %v", data["built"]) + } + if _, hasPoll := data["poll_hint"]; hasPoll { + t.Error("sync success must not carry poll_hint") + } + // meta.json backfill. + b, _ := os.ReadFile(filepath.Join(root, metaRelPath)) + var meta map[string]interface{} + _ = json.Unmarshal(b, &meta) + if meta["online_url"] != "https://x.feishuapp.cn/app/app_x" || meta["app_id"] != "app_x" { + t.Errorf("meta after publish = %v", meta) + } +} + +func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { + root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_2", "status": "pending"}) + if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["built"] != false { + t.Errorf("built = %v, want false with --skip-build", data["built"]) + } + hint, _ := data["poll_hint"].(string) + if !strings.Contains(hint, "+release-get --app-id app_x --release-id rel_2") { + t.Errorf("poll_hint = %q", hint) + } + if _, has := data["online_url"]; has { + t.Error("async must not carry online_url") + } + // No online_url -> no backfill. + b, _ := os.ReadFile(filepath.Join(root, metaRelPath)) + if strings.Contains(string(b), "online_url") { + t.Errorf("meta must not gain online_url on async publish: %s", b) + } +} + +func TestAppDevPublishExecute_BuildFails(t *testing.T) { + chdirProjectRoot(t, `{"app_id":"app_x"}`) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + f := &fakeEnvRunner{stderr: "TS2304: boom", err: errors.New("exit 1")} + withFakeEnvRunner(t, f) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryInternal) + if !strings.Contains(p.Message, "npm run build failed") || !strings.Contains(p.Message, "TS2304") { + t.Errorf("message = %q", p.Message) + } + if !strings.Contains(p.Hint, "--skip-build") { + t.Errorf("hint = %q", p.Hint) + } +} + +func TestAppDevPublishExecute_PreReleaseMissingKVs(t *testing.T) { + root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/pre_release", + Body: map[string]interface{}{ + "code": float64(0), + "data": map[string]interface{}{"kvs": []interface{}{}}, + }, + }) + err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryInternal) + if !strings.Contains(p.Message, "missing upload_url or tos_path") { + t.Errorf("message = %q", p.Message) + } +} + +func TestAppDevPublishExecute_NonHTTPSUploadURL(t *testing.T) { + root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", "http://insecure.example/put", nil) + err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryInternal) + if !strings.Contains(p.Message, "not https") { + t.Errorf("message = %q", p.Message) + } +} + +func TestAppDevPublishExecute_TOS5xx(t *testing.T) { + root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryNetwork) + if !p.Retryable { + t.Error("5xx upload failure must be retryable") + } +} + +func TestAppDevPublishDryRun(t *testing.T) { + root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html"}) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + data, err := decodeDryRunDataMap(stdout.Bytes()) + if err != nil { + t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String()) + } + if data["app_id"] != "app_x" { + t.Errorf("app_id = %v", data["app_id"]) + } + if verr, _ := data["dist_validation_error"].(string); !strings.Contains(verr, "routes.json") { + t.Errorf("dist_validation_error = %v (routes.json missing should surface)", data["dist_validation_error"]) + } + buildCmd, _ := data["build_command"].(string) + if !strings.Contains(buildCmd, "MIAODA_*") { + t.Errorf("build_command = %q", buildCmd) + } +} + +func TestAppsAppDevPublish_Declaration(t *testing.T) { + if AppsAppDevPublish.Command != "+app-dev-publish" { + t.Errorf("Command = %q", AppsAppDevPublish.Command) + } + if AppsAppDevPublish.Risk != "write" { + t.Errorf("Risk = %q", AppsAppDevPublish.Risk) + } + if !AppsAppDevPublish.HasFormat { + t.Error("HasFormat = false") + } + if len(AppsAppDevPublish.Scopes) != 2 { + t.Errorf("Scopes = %v", AppsAppDevPublish.Scopes) + } +} From 45897b1cc215dc39f6749e95304971aaa660da93 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 10:33:18 +0800 Subject: [PATCH 04/51] feat(apps): register app-dev shortcuts --- shortcuts/apps/shortcuts.go | 2 ++ shortcuts/apps/shortcuts_test.go | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index fb08c062c3..702c058429 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -34,6 +34,8 @@ func Shortcuts() []common.Shortcut { AppsMemberSettingsSet, AppsHTMLPublish, AppsInit, + AppsAppDevInitApp, + AppsAppDevPublish, AppsReleaseCreate, AppsReleaseList, AppsReleaseGet, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index a8dec3717a..cf366ce07a 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -10,7 +10,7 @@ import ( ) // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 -// 6 基础 + 1 init + 3 publish + 1 env-pull +// 6 基础 + 1 init + 3 publish + 1 env-pull + 2 app-dev(init-app/publish) // - 6 observability(log-list/log-get/trace-list/trace-get/metric-list/analytics-list) // - 3 env(list/set/delete) // - 23 db(table-list/table-schema/sql/dev-init/data-import/data-export/sync create/list/get/enable/disable/update/delete/changelog-list/ @@ -26,11 +26,11 @@ import ( // - 9 role(role CRUD + role-member list/add/remove + role-match-list) // - 6 creative app member/permission settings // - 7 db-sync(create/list/get/enable/disable/update/delete) -// - 1 user-id-convert = 96。 -func TestAppsShortcuts_Returns96(t *testing.T) { +// - 1 user-id-convert = 98。 +func TestAppsShortcuts_Returns98(t *testing.T) { got := Shortcuts() - if len(got) != 96 { - t.Fatalf("Shortcuts() returned %d entries, want 96", len(got)) + if len(got) != 98 { + t.Fatalf("Shortcuts() returned %d entries, want 98", len(got)) } } From 6d292257c944e3f5e840423a48b4a48c10c4f4a2 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 10:34:21 +0800 Subject: [PATCH 05/51] docs(apps): add app-dev shortcut skill references --- .../references/lark-apps-app-dev-init-app.md | 34 ++++++++++++++ .../references/lark-apps-app-dev-publish.md | 45 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 skills/lark-apps/references/lark-apps-app-dev-init-app.md create mode 100644 skills/lark-apps/references/lark-apps-app-dev-publish.md diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-app.md b/skills/lark-apps/references/lark-apps-app-dev-init-app.md new file mode 100644 index 0000000000..d2fd48e930 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-app-dev-init-app.md @@ -0,0 +1,34 @@ +# apps +app-dev-init-app + +在本地初始化一个产物托管形态的 Web 应用项目(代码留在本地,构建产物后续发布到妙搭)。运行时命令事实以 `lark-cli apps +app-dev-init-app --help` 为准。 + +## 何时用 + +用户要在本地开发一个 Web 应用(纯前端或全栈)并计划后续部署到妙搭时,用它初始化技术栈模板。它不创建妙搭应用、不打任何远端 API、不涉及 git/沙箱;只在本地 scaffold 项目。已有项目目录时不要用它(目标目录必须为空或不存在)。 + +## 命令骨架 + +- 必填:`--type`,取值 `frontend`(映射模板 react-standard-webapp)或 `full_stack`(映射 react-express-standard-fullstack)。 +- 可选:`--dir`,相对路径,默认 `./<模板名>`;目录已存在且非空会被拒绝。 +- 前置:本机需有 Node.js(提供 npx)。内部执行 `npx @lark-apaas/miaoda-cli app init --template <模板名> --skip-install` 完成 scaffold,默认不装依赖(秒级返回)。 + +## 示例 + +```bash +lark-cli apps +app-dev-init-app --type frontend --dir ./my-app +lark-cli apps +app-dev-init-app --type full_stack --dry-run +``` + +## 输出契约 + +返回 `data.dir`(项目目录)、`data.template`、`data.stack` 和 `data.next_steps`(后续步骤清单)。按 next_steps 引导用户: + +1. `cd && npm install && npm run dev` 本地开发预览; +2. 需要发布时先 `lark-cli apps +create --name ` 创建妙搭应用,把返回的 `app_id` 写入项目根的 `.spark/meta.json`; +3. 在项目根运行 `lark-cli apps +app-dev-publish` 构建并发布(见 [lark-apps-app-dev-publish.md](lark-apps-app-dev-publish.md))。 + +## 常见失败 + +- `npx executable not found on PATH`:本机没装 Node.js,转述 hint 让用户安装。 +- `target directory ... already exists and is not empty`:换 `--dir` 或让用户清空目录;不要擅自删除已有内容。 +- `npx app init failed`:多为网络或 registry 问题,转述 stderr 摘要;模板 registry 是 registry.npmmirror.com。 diff --git a/skills/lark-apps/references/lark-apps-app-dev-publish.md b/skills/lark-apps/references/lark-apps-app-dev-publish.md new file mode 100644 index 0000000000..adc1e7327f --- /dev/null +++ b/skills/lark-apps/references/lark-apps-app-dev-publish.md @@ -0,0 +1,45 @@ +# apps +app-dev-publish + +把本地 Web 应用项目一键构建并发布到它的妙搭应用(产物托管形态)。运行时命令事实以 `lark-cli apps +app-dev-publish --help` 为准。 + +## 何时用 + +用 `+app-dev-init-app` 初始化(或按产物协议改造)的本地项目要部署/更新到妙搭时使用。它不适用于 html 应用(走 `+html-publish`)或源码托管应用(走 `+release-create`)。 + +## 命令骨架 + +- **必须在项目根目录执行**:项目根须有 `.spark/meta.json` 且含 `app_id`。命令不接受 `--app-id` / `--path` 参数;产物目录固定为 `./dist`。 +- 可选:`--skip-build`(跳过 `npm run build`,直接发布已有 `./dist`)、`--allow-sensitive`(跳过凭据文件扫描)。 +- 内部流程:读 meta.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → `npm run build`(自动注入这些变量)→ 校验 dist 产物协议 → zip 上传 → 触发发布。 +- 产物协议:`dist/output/` 必须含 `index.html` 与 `routes.json`;`dist/output_resource/` 可选;dist 顶层不允许其他条目。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 + +## 示例 + +```bash +lark-cli apps +app-dev-publish +lark-cli apps +app-dev-publish --skip-build +lark-cli apps +app-dev-publish --dry-run +``` + +## 输出契约 + +- 同步完成:`data.online_url` 直接可访问,同时回填进 `.spark/meta.json`。 +- 异步发布:返回 `data.release_id` 和 `data.poll_hint`;用 `+release-get --app-id --release-id ` 轮询到 `finished` 后读取 `online_url`。 +- 业务失败通常带 `error.hint`,优先转述 hint;网络/服务端 5xx 失败带 `retryable`,可稍后重试。 + +## 前置引导 + +- meta.json 缺 `app_id` 时:先 `lark-cli apps +create --name ` 创建应用,把返回的 `app_id` 写入 `.spark/meta.json` 再发布;应用名可从项目主题生成,不要让用户手动提供 app_id。 +- **`app_id` 不是本会话写入的**(来自历史文件或他人仓库)时,发布前先把目标 `app_id` 告知用户并确认——发布会覆盖该应用的线上内容。 + +## 安全规则 + +- 敏感文件扫描命中(`.env`、`.npmrc` 等)时,**不要自动加 `--allow-sensitive` 重试**;把命中的文件列表转述给用户,由用户决定移除还是明确豁免。 +- 构建环境变量只注入 `pre_release` 下发的 `MIAODA_*` 白名单键;命令会在 stderr 回显实际注入的键名。 + +## 常见失败 + +- `current directory is not a Miaoda app project`:不在项目根执行;`cd` 到含 `.spark/meta.json` 的目录。 +- `dist/output is missing routes.json`:模板构建脚本负责生成;让用户检查是否改动了构建配置,不要手工伪造 routes.json。 +- `npm run build failed`:转述 stderr 摘要让用户修构建错误;用户已手动构建时可用 `--skip-build`。 +- `--skip-build is set but ./dist does not exist`:先构建或去掉 `--skip-build`。 From 8f91de815b10e811b817d3afa1e18fe54f406dbb Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 10:36:44 +0800 Subject: [PATCH 06/51] chore(apps): exempt presigned TOS upload from raw-http lint --- shortcuts/apps/apps_app_dev_publish.go | 1 + 1 file changed, 1 insertion(+) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index b5ed7421c2..17f6ed1dc8 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -312,6 +312,7 @@ var AppsAppDevPublish = common.Shortcut{ return err } + //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(zipball.Body)) if err != nil { return errs.NewNetworkError(errs.SubtypeNetworkTransport, "build TOS upload request").WithCause(err) From 9875a9e4e69ccde0f7b975bf2fe506dde783589f Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 11:04:34 +0800 Subject: [PATCH 07/51] fix(apps): gate dry-run on sensitive files and fix misleading error copy --- shortcuts/apps/apps_app_dev_init_app.go | 7 ++++ shortcuts/apps/apps_app_dev_init_app_test.go | 26 +++++++++++++ shortcuts/apps/apps_app_dev_publish.go | 39 +++++++++++++++++-- shortcuts/apps/apps_app_dev_publish_test.go | 35 ++++++++++++++++- .../references/lark-apps-app-dev-init-app.md | 2 +- 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_init_app.go b/shortcuts/apps/apps_app_dev_init_app.go index ebb36bfd7f..59902c9175 100644 --- a/shortcuts/apps/apps_app_dev_init_app.go +++ b/shortcuts/apps/apps_app_dev_init_app.go @@ -159,6 +159,13 @@ var AppsAppDevInitApp = common.Shortcut{ dry.Set("command", "npx "+strings.Join(appDevInitArgs(template), " ")) dry.Set("target_dir", dir) dry.Set("template", template) + // Surface the same precondition the real run enforces, so a dry-run + // on a non-empty target does not read as "would succeed". + if err := ensureAppDevDirUsable(dir); err != nil { + dry.Set("target_dir_state", "not usable (real run would fail): "+err.Error()) + } else { + dry.Set("target_dir_state", "ok (absent or empty)") + } dry.Set("remote_side_effects", "none (local scaffold via npx)") return dry }, diff --git a/shortcuts/apps/apps_app_dev_init_app_test.go b/shortcuts/apps/apps_app_dev_init_app_test.go index 153ff38cfc..8888b9eeba 100644 --- a/shortcuts/apps/apps_app_dev_init_app_test.go +++ b/shortcuts/apps/apps_app_dev_init_app_test.go @@ -340,4 +340,30 @@ func TestAppDevInitAppDryRun(t *testing.T) { if data["target_dir"] != filepath.Join(".", "react-standard-webapp") { t.Errorf("target_dir = %v", data["target_dir"]) } + if data["target_dir_state"] != "ok (absent or empty)" { + t.Errorf("target_dir_state = %v", data["target_dir_state"]) + } +} + +func TestAppDevInitAppDryRun_DirNotEmptySurfaced(t *testing.T) { + dir := relAppDevDir(t) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "x.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsAppDevInitApp, + []string{"+app-dev-init-app", "--type", "frontend", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + data, err := decodeDryRunDataMap(stdout.Bytes()) + if err != nil { + t.Fatal(err) + } + state, _ := data["target_dir_state"].(string) + if !strings.Contains(state, "not usable") { + t.Errorf("target_dir_state = %q, want non-empty dir surfaced", state) + } } diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 17f6ed1dc8..1ad39780dc 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -151,12 +151,22 @@ func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) } } if len(hits) > 0 { - return nil, sensitiveCandidatesError(hits) + return nil, appDevSensitiveCandidatesError(hits) } } return candidates, nil } +// appDevSensitiveCandidatesError mirrors sensitiveCandidatesError with +// publish-specific wording: this command has no --path flag and the payload +// is always ./dist, so the html-publish message would misdirect the user. +func appDevSensitiveCandidatesError(hits []string) error { + return appsValidationError( + "dist contains %d credential file(s) that should not be published: %s", + len(hits), truncatedJoin(hits, maxSensitiveListInError)). + WithHint("remove these files from the build output, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)") +} + // envCommandRunner runs a subprocess with extra environment variables // appended to the parent env. Separate from commandRunner because only the // build step needs env injection, and a dedicated seam keeps init tests and @@ -222,8 +232,31 @@ var AppsAppDevPublish = common.Shortcut{ return appsFailedPreconditionError(".spark/meta.json has no app_id"). WithHint("create the app with `lark-cli apps +create --name `, then write the returned app_id into .spark/meta.json") } - if err := validateRealAppID(appID); err != nil { - return err + // The app id comes from meta.json, not a flag — a bespoke error here + // instead of validateRealAppID, whose --app-id wording would point the + // user at a flag this command does not have. + if !strings.HasPrefix(appID, "app_") { + return appsFailedPreconditionError( + `.spark/meta.json app_id %q is invalid (must start with "app_")`, appID). + WithHint("fix app_id in .spark/meta.json: find the right id with `lark-cli apps +list`, or create the app with `lark-cli apps +create --name `") + } + // Sensitive-file scan lives in Validate so that --dry-run exits + // non-zero on a hit — the one deliberate exception to dry-run's + // exit-0 convention (mirrors +html-publish). Walk errors (e.g. dist + // missing) are not fatal here; DryRun/Execute surface them with + // richer context. + if !rctx.Bool("allow-sensitive") { + if candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), appDevDistDir); err == nil { + var hits []string + for _, c := range candidates { + if isSensitiveCandidate(appDevDistDir, c) { + hits = append(hits, c.RelPath) + } + } + if len(hits) > 0 { + return appDevSensitiveCandidatesError(hits) + } + } } if rctx.Bool("skip-build") { if _, err := rctx.FileIO().Stat(appDevDistDir); err != nil { diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index aa059fc4fe..7d29c10313 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -331,8 +331,39 @@ func TestAppDevPublishValidate_BadAppID(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) - if !strings.Contains(p.Message, "app_") { - t.Errorf("got %v", p) + if !strings.Contains(p.Message, ".spark/meta.json app_id") { + t.Errorf("message should point at meta.json, got %q", p.Message) + } + // This command has no --app-id flag; the error must not mention one. + if strings.Contains(p.Message, "--app-id") || strings.Contains(p.Hint, "--app-id") { + t.Errorf("error must not reference a nonexistent --app-id flag: %v", p) + } + if !strings.Contains(p.Hint, "+list") { + t.Errorf("hint = %q", p.Hint) + } +} + +func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { + root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + writeDistFiles(t, filepath.Join(root, appDevDistDir), + []string{"output/index.html", "output/routes.json", "output_resource/.env"}) + factory, stdout, _ := newAppsExecuteFactory(t) + // Sensitive hits are the one exception to dry-run's exit-0 convention: + // Validate rejects before the DryRun branch runs. + err := runAppsShortcut(t, AppsAppDevPublish, + []string{"+app-dev-publish", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if !strings.Contains(p.Message, "dist contains") || !strings.Contains(p.Message, "credential file") { + t.Errorf("message = %q", p.Message) + } + // This command has no --path flag; the error must not mention one. + if strings.Contains(p.Message, "--path") { + t.Errorf("error must not reference a nonexistent --path flag: %q", p.Message) + } + // --allow-sensitive waives the gate and dry-run goes back to exit 0. + if err := runAppsShortcut(t, AppsAppDevPublish, + []string{"+app-dev-publish", "--skip-build", "--allow-sensitive", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + t.Errorf("allow-sensitive dry-run should pass: %v", err) } } diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-app.md b/skills/lark-apps/references/lark-apps-app-dev-init-app.md index d2fd48e930..86116e632c 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-init-app.md +++ b/skills/lark-apps/references/lark-apps-app-dev-init-app.md @@ -10,7 +10,7 @@ - 必填:`--type`,取值 `frontend`(映射模板 react-standard-webapp)或 `full_stack`(映射 react-express-standard-fullstack)。 - 可选:`--dir`,相对路径,默认 `./<模板名>`;目录已存在且非空会被拒绝。 -- 前置:本机需有 Node.js(提供 npx)。内部执行 `npx @lark-apaas/miaoda-cli app init --template <模板名> --skip-install` 完成 scaffold,默认不装依赖(秒级返回)。 +- 前置:已完成 `lark-cli config init`(框架级要求,纯本地命令也需要);本机需有 Node.js(提供 npx)。内部执行 `npx @lark-apaas/miaoda-cli app init --template <模板名> --skip-install` 完成 scaffold,默认不装依赖(秒级返回)。 ## 示例 From eb7f0da564473ccd0dc9766688dbc101caaf344f Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 12:02:00 +0800 Subject: [PATCH 08/51] refactor(apps): rename +app-dev-init-app to +app-dev-init-template --- ...t_app.go => apps_app_dev_init_template.go} | 12 ++--- ....go => apps_app_dev_init_template_test.go} | 52 +++++++++---------- shortcuts/apps/apps_app_dev_publish.go | 2 +- shortcuts/apps/apps_app_dev_publish_test.go | 2 +- shortcuts/apps/shortcuts.go | 2 +- ....md => lark-apps-app-dev-init-template.md} | 8 +-- .../references/lark-apps-app-dev-publish.md | 2 +- 7 files changed, 40 insertions(+), 40 deletions(-) rename shortcuts/apps/{apps_app_dev_init_app.go => apps_app_dev_init_template.go} (95%) rename shortcuts/apps/{apps_app_dev_init_app_test.go => apps_app_dev_init_template_test.go} (84%) rename skills/lark-apps/references/{lark-apps-app-dev-init-app.md => lark-apps-app-dev-init-template.md} (88%) diff --git a/shortcuts/apps/apps_app_dev_init_app.go b/shortcuts/apps/apps_app_dev_init_template.go similarity index 95% rename from shortcuts/apps/apps_app_dev_init_app.go rename to shortcuts/apps/apps_app_dev_init_template.go index 59902c9175..40159ca787 100644 --- a/shortcuts/apps/apps_app_dev_init_app.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -27,7 +27,7 @@ const ( // appDevLookPath is swappable in tests to simulate a missing npx/npm binary. var appDevLookPath = exec.LookPath -// appDevTemplateForType maps the +app-dev-init-app --type value to its +// appDevTemplateForType maps the +app-dev-init-template --type value to its // miaoda-cli template name. Unknown types return "". func appDevTemplateForType(appType string) string { switch appType { @@ -115,16 +115,16 @@ func readMetaStack(dir string) (string, bool, error) { return s, true, nil } -// AppsAppDevInitApp scaffolds a local web app project via miaoda-cli +// AppsAppDevInitTemplate scaffolds a local web app project via miaoda-cli // templates (artifact-hosting mode: code stays local, no git, no sandbox). -var AppsAppDevInitApp = common.Shortcut{ +var AppsAppDevInitTemplate = common.Shortcut{ Service: appsService, - Command: "+app-dev-init-app", + Command: "+app-dev-init-template", Description: "Scaffold a local web app project via miaoda-cli templates (artifact-hosting mode, no git/sandbox, no remote API)", Risk: "write", Tips: []string{ - "Example: lark-cli apps +app-dev-init-app --type frontend --dir ./my-app", - "Example: lark-cli apps +app-dev-init-app --type full_stack --dry-run", + "Example: lark-cli apps +app-dev-init-template --type frontend --dir ./my-app", + "Example: lark-cli apps +app-dev-init-template --type full_stack --dry-run", "The scaffold is local-only: create the Miaoda app later with +create and deploy with +app-dev-publish", }, // No remote OAPI is called; explicit []string{} per the convention diff --git a/shortcuts/apps/apps_app_dev_init_app_test.go b/shortcuts/apps/apps_app_dev_init_template_test.go similarity index 84% rename from shortcuts/apps/apps_app_dev_init_app_test.go rename to shortcuts/apps/apps_app_dev_init_template_test.go index 8888b9eeba..4848681aaa 100644 --- a/shortcuts/apps/apps_app_dev_init_app_test.go +++ b/shortcuts/apps/apps_app_dev_init_template_test.go @@ -122,20 +122,20 @@ func TestReadMetaStack(t *testing.T) { // --- declaration & validate tests --- -func TestAppsAppDevInitApp_Declaration(t *testing.T) { - if AppsAppDevInitApp.Command != "+app-dev-init-app" { - t.Errorf("Command = %q", AppsAppDevInitApp.Command) +func TestAppsAppDevInitTemplate_Declaration(t *testing.T) { + if AppsAppDevInitTemplate.Command != "+app-dev-init-template" { + t.Errorf("Command = %q", AppsAppDevInitTemplate.Command) } - if AppsAppDevInitApp.Service != appsService { - t.Errorf("Service = %q", AppsAppDevInitApp.Service) + if AppsAppDevInitTemplate.Service != appsService { + t.Errorf("Service = %q", AppsAppDevInitTemplate.Service) } - if AppsAppDevInitApp.Risk != "write" { - t.Errorf("Risk = %q, want write", AppsAppDevInitApp.Risk) + if AppsAppDevInitTemplate.Risk != "write" { + t.Errorf("Risk = %q, want write", AppsAppDevInitTemplate.Risk) } - if !AppsAppDevInitApp.HasFormat { + if !AppsAppDevInitTemplate.HasFormat { t.Error("HasFormat = false, want true") } - if AppsAppDevInitApp.Scopes == nil { + if AppsAppDevInitTemplate.Scopes == nil { t.Error("Scopes must be non-nil (no remote API => empty slice)") } } @@ -144,7 +144,7 @@ func TestAppsAppDevInitApp_Declaration(t *testing.T) { // registered, mirroring how the shortcut reads them via rctx.Str. func testRuntimeAppDevInit(t *testing.T, appType, dir string) *common.RuntimeContext { t.Helper() - cmd := &cobra.Command{Use: "+app-dev-init-app"} + cmd := &cobra.Command{Use: "+app-dev-init-template"} cmd.Flags().String("type", appType, "") cmd.Flags().String("dir", dir, "") return common.TestNewRuntimeContext(cmd, nil) @@ -160,7 +160,7 @@ func TestAppDevInitAppValidate(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := AppsAppDevInitApp.Validate(context.Background(), testRuntimeAppDevInit(t, tt.appType, tt.dir)) + err := AppsAppDevInitTemplate.Validate(context.Background(), testRuntimeAppDevInit(t, tt.appType, tt.dir)) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("err = %v, want containing %q", err, tt.wantErr) } @@ -172,7 +172,7 @@ func TestAppDevInitAppValidate_NpxMissing(t *testing.T) { orig := appDevLookPath appDevLookPath = func(string) (string, error) { return "", errors.New("not found") } t.Cleanup(func() { appDevLookPath = orig }) - err := AppsAppDevInitApp.Validate(context.Background(), testRuntimeAppDevInit(t, "frontend", "")) + err := AppsAppDevInitTemplate.Validate(context.Background(), testRuntimeAppDevInit(t, "frontend", "")) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition { t.Errorf("subtype = %q, want failed_precondition", p.Subtype) @@ -198,8 +198,8 @@ func TestAppDevInitAppExecute_DelegatesNpx(t *testing.T) { withFakeRunner(t, f) factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) - if err := runAppsShortcut(t, AppsAppDevInitApp, - []string{"+app-dev-init-app", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsAppDevInitTemplate, + []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } c := findCall(f.calls, "npx", "-y") @@ -234,8 +234,8 @@ func TestAppDevInitAppExecute_FullStackTemplate(t *testing.T) { withFakeRunner(t, f) factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) - if err := runAppsShortcut(t, AppsAppDevInitApp, - []string{"+app-dev-init-app", "--type", "full_stack", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsAppDevInitTemplate, + []string{"+app-dev-init-template", "--type", "full_stack", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } if c := findCall(f.calls, "npx", "-y"); c == nil || !containsAll(c, "--template", "react-express-standard-fullstack") { @@ -255,8 +255,8 @@ func TestAppDevInitAppExecute_MetaStackEcho(t *testing.T) { // dir must stay empty for ensureAppDevDirUsable, so use a wrapper runner. wrapped := &metaWritingRunner{inner: f, dir: dir, stack: "custom-stack"} initRunner = wrapped - if err := runAppsShortcut(t, AppsAppDevInitApp, - []string{"+app-dev-init-app", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsAppDevInitTemplate, + []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } data := parseEnvelopeData(t, stdout) @@ -290,8 +290,8 @@ func TestAppDevInitAppExecute_NpxFails(t *testing.T) { withFakeRunner(t, f) factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) - err := runAppsShortcut(t, AppsAppDevInitApp, - []string{"+app-dev-init-app", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsAppDevInitTemplate, + []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryInternal) if !strings.Contains(p.Message, "npx app init failed") || !strings.Contains(p.Message, "boom") { t.Errorf("message = %q", p.Message) @@ -309,8 +309,8 @@ func TestAppDevInitAppExecute_DirNotEmpty(t *testing.T) { f := &fakeCommandRunner{} withFakeRunner(t, f) factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAppDevInitApp, - []string{"+app-dev-init-app", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsAppDevInitTemplate, + []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition { t.Errorf("subtype = %q", p.Subtype) @@ -322,8 +322,8 @@ func TestAppDevInitAppExecute_DirNotEmpty(t *testing.T) { func TestAppDevInitAppDryRun(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - if err := runAppsShortcut(t, AppsAppDevInitApp, - []string{"+app-dev-init-app", "--type", "frontend", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsAppDevInitTemplate, + []string{"+app-dev-init-template", "--type", "frontend", "--as", "user", "--dry-run"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } data, err := decodeDryRunDataMap(stdout.Bytes()) @@ -354,8 +354,8 @@ func TestAppDevInitAppDryRun_DirNotEmptySurfaced(t *testing.T) { t.Fatal(err) } factory, stdout, _ := newAppsExecuteFactory(t) - if err := runAppsShortcut(t, AppsAppDevInitApp, - []string{"+app-dev-init-app", "--type", "frontend", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsAppDevInitTemplate, + []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } data, err := decodeDryRunDataMap(stdout.Bytes()) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 1ad39780dc..2f5ddfa338 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -226,7 +226,7 @@ var AppsAppDevPublish = common.Shortcut{ if !isSpark { return appsFailedPreconditionError( "current directory is not a Miaoda app project (.spark/meta.json not found)"). - WithHint("run this command from the project root; scaffold a project with +app-dev-init-app first") + WithHint("run this command from the project root; scaffold a project with +app-dev-init-template first") } if strings.TrimSpace(appID) == "" { return appsFailedPreconditionError(".spark/meta.json has no app_id"). diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index 7d29c10313..1b40d71136 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -311,7 +311,7 @@ func TestAppDevPublishValidate_NoMeta(t *testing.T) { if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "not a Miaoda app project") { t.Errorf("got %v", p) } - if !strings.Contains(p.Hint, "+app-dev-init-app") { + if !strings.Contains(p.Hint, "+app-dev-init-template") { t.Errorf("hint = %q", p.Hint) } } diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 702c058429..3620de1fb3 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -34,7 +34,7 @@ func Shortcuts() []common.Shortcut { AppsMemberSettingsSet, AppsHTMLPublish, AppsInit, - AppsAppDevInitApp, + AppsAppDevInitTemplate, AppsAppDevPublish, AppsReleaseCreate, AppsReleaseList, diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-app.md b/skills/lark-apps/references/lark-apps-app-dev-init-template.md similarity index 88% rename from skills/lark-apps/references/lark-apps-app-dev-init-app.md rename to skills/lark-apps/references/lark-apps-app-dev-init-template.md index 86116e632c..29d61c0532 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-init-app.md +++ b/skills/lark-apps/references/lark-apps-app-dev-init-template.md @@ -1,6 +1,6 @@ -# apps +app-dev-init-app +# apps +app-dev-init-template -在本地初始化一个产物托管形态的 Web 应用项目(代码留在本地,构建产物后续发布到妙搭)。运行时命令事实以 `lark-cli apps +app-dev-init-app --help` 为准。 +在本地初始化一个产物托管形态的 Web 应用项目(代码留在本地,构建产物后续发布到妙搭)。运行时命令事实以 `lark-cli apps +app-dev-init-template --help` 为准。 ## 何时用 @@ -15,8 +15,8 @@ ## 示例 ```bash -lark-cli apps +app-dev-init-app --type frontend --dir ./my-app -lark-cli apps +app-dev-init-app --type full_stack --dry-run +lark-cli apps +app-dev-init-template --type frontend --dir ./my-app +lark-cli apps +app-dev-init-template --type full_stack --dry-run ``` ## 输出契约 diff --git a/skills/lark-apps/references/lark-apps-app-dev-publish.md b/skills/lark-apps/references/lark-apps-app-dev-publish.md index adc1e7327f..23cab3bd1e 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-publish.md +++ b/skills/lark-apps/references/lark-apps-app-dev-publish.md @@ -4,7 +4,7 @@ ## 何时用 -用 `+app-dev-init-app` 初始化(或按产物协议改造)的本地项目要部署/更新到妙搭时使用。它不适用于 html 应用(走 `+html-publish`)或源码托管应用(走 `+release-create`)。 +用 `+app-dev-init-template` 初始化(或按产物协议改造)的本地项目要部署/更新到妙搭时使用。它不适用于 html 应用(走 `+html-publish`)或源码托管应用(走 `+release-create`)。 ## 命令骨架 From 8cd90069708480bc37b6011aa416f852431d1d9c Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 12:27:39 +0800 Subject: [PATCH 09/51] feat(apps): render init-template natively from npm registry --- shortcuts/apps/app_dev_template_fetch.go | 299 ++++++++++++ shortcuts/apps/apps_app_dev_init_template.go | 107 ++--- .../apps/apps_app_dev_init_template_test.go | 435 +++++++++++++----- .../lark-apps-app-dev-init-template.md | 6 +- 4 files changed, 655 insertions(+), 192 deletions(-) create mode 100644 shortcuts/apps/app_dev_template_fetch.go diff --git a/shortcuts/apps/app_dev_template_fetch.go b/shortcuts/apps/app_dev_template_fetch.go new file mode 100644 index 0000000000..d9f63b7ec5 --- /dev/null +++ b/shortcuts/apps/app_dev_template_fetch.go @@ -0,0 +1,299 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" +) + +// appDevTemplatePkgPrefix is the npm package naming convention for artifact +// templates, aligned with miaoda-cli's TEMPLATE_PACKAGE_BY_STACK +// ("@lark-apaas/coding-template-" + stack short name). +const appDevTemplatePkgPrefix = "@lark-apaas/coding-template-" + +// appDevTemplateEntryPrefix is the tarball path prefix that holds the +// renderable template files (npm tarballs root at "package/"). +const appDevTemplateEntryPrefix = "package/template/" + +// appDevRegistryBase is the npm registry used to resolve template packages. +// Package-level var so unit tests can point it at an httptest server. +var appDevRegistryBase = npmRegistry + +// Decompression-bomb / runaway-template caps. Vars (not consts) so unit tests +// can shrink them to cover the rejection paths; defaults are far above any +// legitimate template. +var ( + appDevMaxTemplateTgzBytes int64 = 20 * 1024 * 1024 + appDevMaxTemplateExtractBytes int64 = 100 * 1024 * 1024 + appDevMaxTemplateFiles = 2000 +) + +// appDevTemplatePackageName maps a template short name to its npm package. +func appDevTemplatePackageName(template string) string { + return appDevTemplatePkgPrefix + template +} + +// npmPackageMeta is the subset of the npm registry package document the +// fetch needs: latest dist-tag plus each version's tarball URL. +type npmPackageMeta struct { + DistTags map[string]string `json:"dist-tags"` + Versions map[string]struct { + Dist struct { + Tarball string `json:"tarball"` + } `json:"dist"` + } `json:"versions"` +} + +// fetchAppDevTemplateMeta resolves the template package's latest version and +// tarball URL from the npm registry. Only https tarball URLs are accepted. +func fetchAppDevTemplateMeta(ctx context.Context, pkg string) (version, tarballURL string, err error) { + metaURL := strings.TrimRight(appDevRegistryBase, "/") + "/" + pkg + body, err := appDevHTTPGet(ctx, metaURL, appDevMaxTemplateTgzBytes, + "the template package may not be published yet; ask the artifact team, or check network/registry access") + if err != nil { + return "", "", err + } + var meta npmPackageMeta + if err := json.Unmarshal(body, &meta); err != nil { + return "", "", appsSubprocessEnvelopeError("npm registry metadata for %s is not valid JSON", pkg) + } + latest := meta.DistTags["latest"] + if latest == "" { + return "", "", appsSubprocessEnvelopeError("npm registry metadata for %s has no latest dist-tag", pkg) + } + v, ok := meta.Versions[latest] + if !ok || v.Dist.Tarball == "" { + return "", "", appsSubprocessEnvelopeError("npm registry metadata for %s@%s has no tarball URL", pkg, latest) + } + u, perr := url.Parse(v.Dist.Tarball) + if perr != nil || u.Scheme != "https" { + return "", "", appsSubprocessEnvelopeError("npm registry tarball URL for %s@%s is not https; refusing to download", pkg, latest) + } + // Same-origin constraint: npm registries serve tarballs from the registry + // host itself, so a cross-host URL in the metadata is a red flag (metadata + // tampering / registry compromise) — refuse rather than follow it. + if reg, rerr := url.Parse(appDevRegistryBase); rerr != nil || u.Host != reg.Host { + return "", "", appsSubprocessEnvelopeError("npm registry tarball URL host %q differs from registry host; refusing to download", u.Host) + } + return latest, v.Dist.Tarball, nil +} + +// appDevHTTPGet fetches a URL with a hard size cap. notFoundHint decorates the +// 404 error (the caller knows what a missing resource means in its context). +func appDevHTTPGet(ctx context.Context, rawURL string, maxBytes int64, notFoundHint string) ([]byte, error) { + //nolint:forbidigo // npm registry download is not a Lark API call; RuntimeContext.DoAPI does not apply. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "build registry request").WithCause(err) + } + resp, err := appDevNewTransferClient().Do(req) //nolint:forbidigo // see above. + if err != nil { + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "npm registry request failed").WithCause(err).WithRetryable() + } + defer resp.Body.Close() + switch { + case resp.StatusCode == http.StatusNotFound: + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, + "npm registry returned 404 for %s", rawURL).WithHint(notFoundHint) + case resp.StatusCode >= 500: + return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, + "npm registry returned HTTP %d", resp.StatusCode).WithRetryable() + case resp.StatusCode >= 400: + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, + "npm registry returned HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) + if err != nil { + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "read registry response").WithCause(err).WithRetryable() + } + if int64(len(body)) > maxBytes { + return nil, appsValidationError("registry response exceeds %d bytes limit", maxBytes). + WithHint("the template package is unexpectedly large; contact the artifact team") + } + return body, nil +} + +// renderedTemplate reports what renderAppDevTemplate materialized. +type renderedTemplate struct { + ArchType interface{} + Files int +} + +// templatePkgJSON is the subset of the template package's own package.json +// the renderer reads (archType rides on miaodaTemplate, set by the template). +type templatePkgJSON struct { + MiaodaTemplate struct { + ArchType interface{} `json:"archType"` + } `json:"miaodaTemplate"` +} + +// renamedTemplateFiles maps placeholder names shipped in the tarball to their +// real dotfile names (npm pack strips .npmrc; .gitignore conflicts with +// platform repos) — aligned with miaoda-cli's RENAME_FILES. +var renamedTemplateFiles = map[string]string{ + "_gitignore": ".gitignore", + "_npmrc": ".npmrc", +} + +// placeholderTemplateFiles are the display-only files whose {{projectName}} +// placeholder is replaced after extraction — aligned with miaoda-cli's +// renderTemplate (package.json keeps a fixed name on purpose there). +var placeholderTemplateFiles = []string{"index.html", "README.md"} + +// renderAppDevTemplate extracts the package/template/ subtree of an npm +// template tarball into targetDir and applies the rename + placeholder +// conventions. Only regular files under the template prefix are written; +// symlinks, hardlinks, and traversal paths are rejected or skipped. +func renderAppDevTemplate(targetDir, projectName string, tgz []byte) (*renderedTemplate, error) { + gz, err := gzip.NewReader(bytes.NewReader(tgz)) + if err != nil { + return nil, appsSubprocessEnvelopeError("template tarball is not gzip: %v", err) + } + defer gz.Close() + tr := tar.NewReader(gz) + var total int64 + var pkgJSONRaw []byte + files := 0 + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, appsSubprocessEnvelopeError("read template tarball: %v", err) + } + raw := strings.TrimPrefix(hdr.Name, "./") + // Fail closed on the RAW entry name before any cleaning: a template + // carrying traversal or backslash entries is malformed or malicious, + // and partially rendering it would hide that. + if isUnsafeRelPath(raw) || strings.ContainsRune(raw, '\\') { + return nil, appsSubprocessEnvelopeError("template tarball entry %q escapes the target directory; refusing to extract", hdr.Name) + } + name := path.Clean(raw) + if hdr.Typeflag == tar.TypeSymlink || hdr.Typeflag == tar.TypeLink { + // Never materialize links from a downloaded archive — a link + // pointing outside targetDir would bypass the path checks below. + continue + } + if hdr.Typeflag != tar.TypeReg { + continue + } + if name == "package/package.json" { + raw, err := io.ReadAll(io.LimitReader(tr, 1<<20)) + if err != nil { + return nil, appsSubprocessEnvelopeError("read template package.json: %v", err) + } + pkgJSONRaw = raw + continue + } + if !strings.HasPrefix(name, appDevTemplateEntryPrefix) { + continue + } + rel := strings.TrimPrefix(name, appDevTemplateEntryPrefix) + // isUnsafeRelPath handles forward-slash traversal; the extra checks + // reject backslashes and Windows drive/reserved forms that only bite + // after filepath.FromSlash on Windows (security-review requirement). + if rel == "" || isUnsafeRelPath(rel) || + strings.ContainsRune(rel, '\\') || !filepath.IsLocal(filepath.FromSlash(rel)) { + return nil, appsSubprocessEnvelopeError("template tarball entry %q escapes the target directory; refusing to extract", hdr.Name) + } + files++ + if files > appDevMaxTemplateFiles { + return nil, appsValidationError("template contains more than %d files; refusing to extract", appDevMaxTemplateFiles). + WithHint("the template package looks malformed; contact the artifact team") + } + dest := filepath.Join(targetDir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard); targetDir is validated relative-only. + return nil, appsFileIOError(err, "create template directory for %s failed: %v", rel, err) + } + remaining := appDevMaxTemplateExtractBytes - total + out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) //nolint:forbidigo // see above. + if err != nil { + return nil, appsFileIOError(err, "create template file %s failed: %v", rel, err) + } + n, err := io.Copy(out, io.LimitReader(tr, remaining+1)) + out.Close() + if err != nil { + return nil, appsFileIOError(err, "write template file %s failed: %v", rel, err) + } + total += n + if total > appDevMaxTemplateExtractBytes { + return nil, appsValidationError("template extraction exceeds %d bytes limit", appDevMaxTemplateExtractBytes). + WithHint("the template package looks malformed; contact the artifact team") + } + } + + for from, to := range renamedTemplateFiles { + fromPath := filepath.Join(targetDir, from) + if _, err := os.Stat(fromPath); err == nil { //nolint:forbidigo // see above. + if err := os.Rename(fromPath, filepath.Join(targetDir, to)); err != nil { //nolint:forbidigo // see above. + return nil, appsFileIOError(err, "rename template file %s failed: %v", from, err) + } + } + } + for _, rel := range placeholderTemplateFiles { + p := filepath.Join(targetDir, rel) + b, err := os.ReadFile(p) //nolint:forbidigo // see above. + if err != nil { + continue + } + replaced := strings.ReplaceAll(string(b), "{{projectName}}", projectName) + if replaced != string(b) { + if err := os.WriteFile(p, []byte(replaced), 0o644); err != nil { //nolint:forbidigo // see above. + return nil, appsFileIOError(err, "write template file %s failed: %v", rel, err) + } + } + } + + rendered := &renderedTemplate{Files: files} + if len(pkgJSONRaw) > 0 { + var pkg templatePkgJSON + if err := json.Unmarshal(pkgJSONRaw, &pkg); err == nil { + rendered.ArchType = pkg.MiaodaTemplate.ArchType + } + } + return rendered, nil +} + +// writeAppDevSparkMeta merge-writes {stack, version, archType} into +// /.spark/meta.json, creating the directory as needed. Field names align +// with miaoda-cli's SparkMeta so downstream tooling reads one format. +func writeAppDevSparkMeta(dir, stack, version string, archType interface{}) error { + sparkDir := filepath.Join(dir, ".spark") + if err := os.MkdirAll(sparkDir, 0o755); err != nil { //nolint:forbidigo // see renderAppDevTemplate. + return appsFileIOError(err, "create .spark directory failed: %v", err) + } + metaPath := filepath.Join(dir, metaRelPath) + meta := map[string]interface{}{} + if b, err := os.ReadFile(metaPath); err == nil { //nolint:forbidigo // see above. + _ = json.Unmarshal(b, &meta) + } + meta["stack"] = stack + meta["version"] = version + if archType != nil { + meta["archType"] = archType + } + out, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err) + } + if err := os.WriteFile(metaPath, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // see above. + return appsFileIOError(err, "write %s failed: %v", metaRelPath, err) + } + return nil +} diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_app_dev_init_template.go index 40159ca787..381d79062a 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -5,7 +5,6 @@ package apps import ( "context" - "encoding/json" "fmt" "io" "os" @@ -16,19 +15,21 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -// Templates provided by @lark-apaas/miaoda-cli for the artifact-hosting mode. -// The CLI only maps --type to a template name; template content is owned and -// iterated by the miaoda-cli package. +// Template short names provided by the artifact team as npm packages +// (@lark-apaas/coding-template-). The CLI maps --type to a template +// name and renders the package natively; template content is owned and +// iterated by the artifact team. const ( appDevTemplateFrontend = "react-standard-webapp" appDevTemplateFullstack = "react-express-standard-fullstack" ) -// appDevLookPath is swappable in tests to simulate a missing npx/npm binary. +// appDevLookPath is swappable in tests to simulate a missing binary +// (+app-dev-publish uses it for its npm precondition check). var appDevLookPath = exec.LookPath // appDevTemplateForType maps the +app-dev-init-template --type value to its -// miaoda-cli template name. Unknown types return "". +// template short name. Unknown types return "". func appDevTemplateForType(appType string) string { switch appType { case "frontend": @@ -39,16 +40,6 @@ func appDevTemplateForType(appType string) string { return "" } -// appDevInitArgs builds the npx argv for scaffolding via miaoda-cli. -// --skip-install keeps the command fast; dependency install is left to the -// user (agents should not block minutes on npm install). -func appDevInitArgs(template string) []string { - return []string{ - "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, - "app", "init", "--template", template, "--skip-install", - } -} - // resolveAppDevDir returns the scaffold target directory: --dir when set, // otherwise ./. func resolveAppDevDir(dir, template string) string { @@ -80,7 +71,7 @@ func validateAppDevDir(dir string) error { } // ensureAppDevDirUsable requires the scaffold target to be absent or an empty -// directory so miaoda-cli never writes into (or over) existing content. +// directory so the template never writes into (or over) existing content. func ensureAppDevDirUsable(dir string) error { entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard); dir is validated relative-only by validateAppDevDir. if err != nil { @@ -97,43 +88,26 @@ func ensureAppDevDirUsable(dir string) error { return nil } -// readMetaStack reads /.spark/meta.json and returns its stack field. -// Mirrors readMetaAppID: (value, fileExists, error). -func readMetaStack(dir string) (string, bool, error) { - b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // same rationale as readMetaAppID - if err != nil { - if os.IsNotExist(err) { - return "", false, nil - } - return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err) - } - var meta map[string]interface{} - if err := json.Unmarshal(b, &meta); err != nil { - return "", true, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err) - } - s, _ := meta["stack"].(string) - return s, true, nil -} - -// AppsAppDevInitTemplate scaffolds a local web app project via miaoda-cli -// templates (artifact-hosting mode: code stays local, no git, no sandbox). +// AppsAppDevInitTemplate scaffolds a local web app project from an npm +// template package (artifact-hosting mode: code stays local, no git, no +// sandbox, no Node required for this step). var AppsAppDevInitTemplate = common.Shortcut{ Service: appsService, Command: "+app-dev-init-template", - Description: "Scaffold a local web app project via miaoda-cli templates (artifact-hosting mode, no git/sandbox, no remote API)", + Description: "Scaffold a local web app project from an npm template package (artifact-hosting mode, no git/sandbox/Node, no Lark API)", Risk: "write", Tips: []string{ "Example: lark-cli apps +app-dev-init-template --type frontend --dir ./my-app", "Example: lark-cli apps +app-dev-init-template --type full_stack --dry-run", "The scaffold is local-only: create the Miaoda app later with +create and deploy with +app-dev-publish", }, - // No remote OAPI is called; explicit []string{} per the convention + // No Lark OAPI is called; explicit []string{} per the convention // enforced by TestAllShortcutsScopesNotNil. Scopes: []string{}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ - {Name: "type", Desc: "app type; maps to a miaoda-cli template (frontend=react-standard-webapp, full_stack=react-express-standard-fullstack)", Enum: []string{"frontend", "full_stack"}}, + {Name: "type", Desc: "app type; maps to a template package (frontend=react-standard-webapp, full_stack=react-express-standard-fullstack)", Enum: []string{"frontend", "full_stack"}}, {Name: "dir", Desc: "target directory, relative path (default ./); must be new or empty"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -142,21 +116,16 @@ var AppsAppDevInitTemplate = common.Shortcut{ return appsValidationParamError("--type", "--type is required"). WithHint("valid values: frontend | full_stack") } - if err := validateAppDevDir(rctx.Str("dir")); err != nil { - return err - } - if _, err := appDevLookPath("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") - } - return nil + return validateAppDevDir(rctx.Str("dir")) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { template := appDevTemplateForType(strings.TrimSpace(rctx.Str("type"))) dir := resolveAppDevDir(rctx.Str("dir"), template) + pkg := appDevTemplatePackageName(template) dry := common.NewDryRunAPI(). - Desc("Scaffold a local web app project via miaoda-cli (local npx, no remote API)") - dry.Set("command", "npx "+strings.Join(appDevInitArgs(template), " ")) + Desc("Scaffold a local web app project by downloading an npm template package (read-only registry fetch, no Lark API)") + dry.Set("template_package", pkg) + dry.Set("registry_url", strings.TrimRight(appDevRegistryBase, "/")+"/"+pkg) dry.Set("target_dir", dir) dry.Set("template", template) // Surface the same precondition the real run enforces, so a dry-run @@ -166,7 +135,7 @@ var AppsAppDevInitTemplate = common.Shortcut{ } else { dry.Set("target_dir_state", "ok (absent or empty)") } - dry.Set("remote_side_effects", "none (local scaffold via npx)") + dry.Set("remote_side_effects", "read-only npm registry download, no Lark API") return dry }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -175,21 +144,29 @@ var AppsAppDevInitTemplate = common.Shortcut{ if err := ensureAppDevDirUsable(dir); err != nil { return err } + pkg := appDevTemplatePackageName(template) + fmt.Fprintf(rctx.IO().ErrOut, "fetching template package %s...\n", pkg) + version, tarballURL, err := fetchAppDevTemplateMeta(ctx, pkg) + if err != nil { + return err + } + tgz, err := appDevHTTPGet(ctx, tarballURL, appDevMaxTemplateTgzBytes, + "the template tarball is missing on the registry; contact the artifact team") + if err != nil { + return err + } if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:forbidigo // see ensureAppDevDirUsable return appsFileIOError(err, "create target directory %s failed: %v", dir, err) } - if _, stderr, err := initRunner.Run(ctx, dir, "npx", appDevInitArgs(template)...); err != nil { - return appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err)). - WithHint("check your network and Node.js version, then retry; the template registry is https://registry.npmmirror.com") + rendered, err := renderAppDevTemplate(dir, filepath.Base(dir), tgz) + if err != nil { + return err } - // Light acceptance check on the template output: echo the stack from - // .spark/meta.json when present; a missing file is the template's - // contract problem, not a command failure. - stack := template - if s, ok, err := readMetaStack(dir); err == nil && ok && s != "" { - stack = s - } else if err == nil && !ok { - fmt.Fprintf(rctx.IO().ErrOut, "warning: %s missing under %s; the miaoda-cli template should produce it\n", metaRelPath, dir) + if rendered.ArchType == nil { + fmt.Fprintf(rctx.IO().ErrOut, "warning: template package %s@%s has no miaodaTemplate.archType; the template should declare it\n", pkg, version) + } + if err := writeAppDevSparkMeta(dir, template, version, rendered.ArchType); err != nil { + return err } nextSteps := []string{ fmt.Sprintf("cd %s && npm install && npm run dev", dir), @@ -199,11 +176,13 @@ var AppsAppDevInitTemplate = common.Shortcut{ data := map[string]interface{}{ "dir": dir, "template": template, - "stack": stack, + "stack": template, + "version": version, + "files": rendered.Files, "next_steps": nextSteps, } rctx.OutFormat(data, nil, func(w io.Writer) { - fmt.Fprintf(w, "dir: %s\ntemplate: %s\nstack: %s\nnext steps:\n", dir, template, stack) + fmt.Fprintf(w, "dir: %s\ntemplate: %s@%s\nfiles: %d\nnext steps:\n", dir, template, version, rendered.Files) for _, s := range nextSteps { fmt.Fprintf(w, " - %s\n", s) } diff --git a/shortcuts/apps/apps_app_dev_init_template_test.go b/shortcuts/apps/apps_app_dev_init_template_test.go index 4848681aaa..91a8afebeb 100644 --- a/shortcuts/apps/apps_app_dev_init_template_test.go +++ b/shortcuts/apps/apps_app_dev_init_template_test.go @@ -4,11 +4,15 @@ package apps import ( + "archive/tar" + "bytes" + "compress/gzip" "context" - "errors" + "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" - "reflect" "strings" "testing" @@ -38,14 +42,9 @@ func TestAppDevTemplateForType(t *testing.T) { } } -func TestAppDevInitArgs(t *testing.T) { - got := appDevInitArgs("react-standard-webapp") - want := []string{ - "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, - "app", "init", "--template", "react-standard-webapp", "--skip-install", - } - if !reflect.DeepEqual(got, want) { - t.Errorf("appDevInitArgs = %v, want %v", got, want) +func TestAppDevTemplatePackageName(t *testing.T) { + if got := appDevTemplatePackageName("react-standard-webapp"); got != "@lark-apaas/coding-template-react-standard-webapp" { + t.Errorf("package name = %q", got) } } @@ -98,25 +97,262 @@ func TestEnsureAppDevDirUsable(t *testing.T) { if !ok || p.Subtype != errs.SubtypeFailedPrecondition { t.Errorf("want failed_precondition, got %v", err) } - if !strings.Contains(p.Message, "already exists and is not empty") { - t.Errorf("message = %q", p.Message) +} + +// --- template tgz test fixture --- + +type tgzEntry struct { + name string + body string + typeflag byte + linkname string +} + +// buildTemplateTgz assembles an npm-style template tarball in memory. +func buildTemplateTgz(t *testing.T, entries []tgzEntry) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, e := range entries { + tf := e.typeflag + if tf == 0 { + tf = tar.TypeReg + } + hdr := &tar.Header{Name: e.name, Mode: 0o644, Size: int64(len(e.body)), Typeflag: tf, Linkname: e.linkname} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if tf == tar.TypeReg { + if _, err := tw.Write([]byte(e.body)); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func defaultTemplateEntries() []tgzEntry { + return []tgzEntry{ + {name: "package/package.json", body: `{"name":"@lark-apaas/coding-template-react-standard-webapp","version":"1.2.3","miaodaTemplate":{"archType":2}}`}, + {name: "package/template/index.html", body: "{{projectName}}"}, + {name: "package/template/README.md", body: "# {{projectName}}"}, + {name: "package/template/src/App.tsx", body: "export default 1"}, + {name: "package/template/_gitignore", body: "node_modules\n"}, + {name: "package/template/_npmrc", body: "registry=x\n"}, + {name: "package/README.md", body: "pkg readme, not extracted"}, + } +} + +// withFakeRegistry starts a TLS registry server that serves metadata + tarball +// for pkg, and points appDevRegistryBase / appDevNewTransferClient at it. +func withFakeRegistry(t *testing.T, pkg string, tgz []byte) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + mux.HandleFunc("/"+pkg, func(w http.ResponseWriter, _ *http.Request) { + meta := map[string]interface{}{ + "dist-tags": map[string]string{"latest": "1.2.3"}, + "versions": map[string]interface{}{ + "1.2.3": map[string]interface{}{ + "dist": map[string]string{"tarball": srv.URL + "/tarball.tgz"}, + }, + }, + } + _ = json.NewEncoder(w).Encode(meta) + }) + mux.HandleFunc("/tarball.tgz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(tgz) + }) + srv = httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + origBase, origClient := appDevRegistryBase, appDevNewTransferClient + appDevRegistryBase = srv.URL + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevRegistryBase, appDevNewTransferClient = origBase, origClient }) + return srv +} + +// --- render tests --- + +func TestRenderAppDevTemplate(t *testing.T) { + dir := t.TempDir() + rendered, err := renderAppDevTemplate(dir, "my-app", buildTemplateTgz(t, defaultTemplateEntries())) + if err != nil { + t.Fatal(err) + } + if rendered.Files != 5 { + t.Errorf("Files = %d, want 5 (template subtree only)", rendered.Files) + } + if rendered.ArchType != float64(2) { + t.Errorf("ArchType = %v (%T), want 2", rendered.ArchType, rendered.ArchType) + } + // Placeholder replaced. + b, _ := os.ReadFile(filepath.Join(dir, "index.html")) + if string(b) != "my-app" { + t.Errorf("index.html = %q", b) + } + // Renames applied. + if _, err := os.Stat(filepath.Join(dir, ".gitignore")); err != nil { + t.Error("_gitignore must be renamed to .gitignore") + } + if _, err := os.Stat(filepath.Join(dir, ".npmrc")); err != nil { + t.Error("_npmrc must be renamed to .npmrc") + } + if _, err := os.Stat(filepath.Join(dir, "_gitignore")); !os.IsNotExist(err) { + t.Error("_gitignore placeholder must not remain") + } + // Non-template pkg files not extracted. + if _, err := os.Stat(filepath.Join(dir, "package")); !os.IsNotExist(err) { + t.Error("files outside package/template/ must not be extracted") + } + // Nested file extracted. + if _, err := os.Stat(filepath.Join(dir, "src", "App.tsx")); err != nil { + t.Error("nested template file missing") + } +} + +func TestRenderAppDevTemplate_RejectsTraversal(t *testing.T) { + dir := t.TempDir() + tgz := buildTemplateTgz(t, []tgzEntry{ + {name: "package/template/../../evil.txt", body: "x"}, + }) + if _, err := renderAppDevTemplate(dir, "p", tgz); err == nil || !strings.Contains(err.Error(), "escapes") { + t.Errorf("traversal entry must be rejected, got %v", err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "evil.txt")); !os.IsNotExist(err) { + t.Error("traversal file must not be written") + } +} + +func TestRenderAppDevTemplate_SkipsSymlinks(t *testing.T) { + dir := t.TempDir() + tgz := buildTemplateTgz(t, []tgzEntry{ + {name: "package/template/link", typeflag: tar.TypeSymlink, linkname: "/etc/passwd"}, + {name: "package/template/index.html", body: "ok"}, + }) + rendered, err := renderAppDevTemplate(dir, "p", tgz) + if err != nil { + t.Fatal(err) + } + if rendered.Files != 1 { + t.Errorf("Files = %d, want 1 (symlink skipped)", rendered.Files) + } + if _, err := os.Lstat(filepath.Join(dir, "link")); !os.IsNotExist(err) { + t.Error("symlink must not be materialized") + } +} + +func TestRenderAppDevTemplate_ExtractCap(t *testing.T) { + orig := appDevMaxTemplateExtractBytes + appDevMaxTemplateExtractBytes = 4 + t.Cleanup(func() { appDevMaxTemplateExtractBytes = orig }) + tgz := buildTemplateTgz(t, []tgzEntry{ + {name: "package/template/big.txt", body: "0123456789"}, + }) + if _, err := renderAppDevTemplate(t.TempDir(), "p", tgz); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Errorf("extract cap must reject, got %v", err) + } +} + +func TestRenderAppDevTemplate_FileCountCap(t *testing.T) { + orig := appDevMaxTemplateFiles + appDevMaxTemplateFiles = 1 + t.Cleanup(func() { appDevMaxTemplateFiles = orig }) + tgz := buildTemplateTgz(t, []tgzEntry{ + {name: "package/template/a.txt", body: "a"}, + {name: "package/template/b.txt", body: "b"}, + }) + if _, err := renderAppDevTemplate(t.TempDir(), "p", tgz); err == nil || !strings.Contains(err.Error(), "more than") { + t.Errorf("file count cap must reject, got %v", err) } } -func TestReadMetaStack(t *testing.T) { +func TestWriteAppDevSparkMeta(t *testing.T) { dir := t.TempDir() - if s, ok, err := readMetaStack(dir); s != "" || ok || err != nil { - t.Errorf("missing meta: got (%q,%v,%v)", s, ok, err) + if err := writeAppDevSparkMeta(dir, "react-standard-webapp", "1.2.3", float64(2)); err != nil { + t.Fatal(err) } - if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil { + b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) + if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"stack":"react-standard-webapp","version":"1.0.0"}`), 0o644); err != nil { + var meta map[string]interface{} + if err := json.Unmarshal(b, &meta); err != nil { t.Fatal(err) } - s, ok, err := readMetaStack(dir) - if err != nil || !ok || s != "react-standard-webapp" { - t.Errorf("got (%q,%v,%v)", s, ok, err) + if meta["stack"] != "react-standard-webapp" || meta["version"] != "1.2.3" || meta["archType"] != float64(2) { + t.Errorf("meta = %v", meta) + } +} + +// --- fetch tests --- + +func TestFetchAppDevTemplateMeta_RejectsNonHTTPSTarball(t *testing.T) { + pkg := "@lark-apaas/coding-template-react-standard-webapp" + mux := http.NewServeMux() + mux.HandleFunc("/"+pkg, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"dist":{"tarball":"http://insecure.example/t.tgz"}}}}`)) + }) + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + origBase, origClient := appDevRegistryBase, appDevNewTransferClient + appDevRegistryBase = srv.URL + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevRegistryBase, appDevNewTransferClient = origBase, origClient }) + + _, _, err := fetchAppDevTemplateMeta(context.Background(), pkg) + if err == nil || !strings.Contains(err.Error(), "not https") { + t.Errorf("non-https tarball must be rejected, got %v", err) + } +} + +func TestFetchAppDevTemplateMeta_RejectsCrossHostTarball(t *testing.T) { + pkg := "@lark-apaas/coding-template-react-standard-webapp" + mux := http.NewServeMux() + mux.HandleFunc("/"+pkg, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"dist":{"tarball":"https://evil.example/t.tgz"}}}}`)) + }) + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + origBase, origClient := appDevRegistryBase, appDevNewTransferClient + appDevRegistryBase = srv.URL + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevRegistryBase, appDevNewTransferClient = origBase, origClient }) + + _, _, err := fetchAppDevTemplateMeta(context.Background(), pkg) + if err == nil || !strings.Contains(err.Error(), "differs from registry host") { + t.Errorf("cross-host tarball must be rejected, got %v", err) + } +} + +func TestRenderAppDevTemplate_RejectsBackslashEntry(t *testing.T) { + tgz := buildTemplateTgz(t, []tgzEntry{ + {name: `package/template/..\evil.txt`, body: "x"}, + }) + if _, err := renderAppDevTemplate(t.TempDir(), "p", tgz); err == nil || !strings.Contains(err.Error(), "escapes") { + t.Errorf("backslash entry must be rejected, got %v", err) + } +} + +func TestFetchAppDevTemplateMeta_404(t *testing.T) { + srv := httptest.NewTLSServer(http.NotFoundHandler()) + t.Cleanup(srv.Close) + origBase, origClient := appDevRegistryBase, appDevNewTransferClient + appDevRegistryBase = srv.URL + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevRegistryBase, appDevNewTransferClient = origBase, origClient }) + + _, _, err := fetchAppDevTemplateMeta(context.Background(), "@lark-apaas/coding-template-x") + p := requireAppsProblem(t, err, errs.CategoryNetwork) + if !strings.Contains(p.Hint, "not be published") { + t.Errorf("404 hint = %q", p.Hint) } } @@ -136,12 +372,10 @@ func TestAppsAppDevInitTemplate_Declaration(t *testing.T) { t.Error("HasFormat = false, want true") } if AppsAppDevInitTemplate.Scopes == nil { - t.Error("Scopes must be non-nil (no remote API => empty slice)") + t.Error("Scopes must be non-nil (no Lark API => empty slice)") } } -// testRuntimeAppDevInit builds a RuntimeContext with the type/dir flags -// registered, mirroring how the shortcut reads them via rctx.Str. func testRuntimeAppDevInit(t *testing.T, appType, dir string) *common.RuntimeContext { t.Helper() cmd := &cobra.Command{Use: "+app-dev-init-template"} @@ -150,7 +384,7 @@ func testRuntimeAppDevInit(t *testing.T, appType, dir string) *common.RuntimeCon return common.TestNewRuntimeContext(cmd, nil) } -func TestAppDevInitAppValidate(t *testing.T) { +func TestAppDevInitTemplateValidate(t *testing.T) { tests := []struct { name, appType, dir, wantErr string }{ @@ -168,21 +402,7 @@ func TestAppDevInitAppValidate(t *testing.T) { } } -func TestAppDevInitAppValidate_NpxMissing(t *testing.T) { - orig := appDevLookPath - appDevLookPath = func(string) (string, error) { return "", errors.New("not found") } - t.Cleanup(func() { appDevLookPath = orig }) - err := AppsAppDevInitTemplate.Validate(context.Background(), testRuntimeAppDevInit(t, "frontend", "")) - p := requireAppsProblem(t, err, errs.CategoryValidation) - if p.Subtype != errs.SubtypeFailedPrecondition { - t.Errorf("subtype = %q, want failed_precondition", p.Subtype) - } - if !strings.Contains(p.Hint, "Node.js") { - t.Errorf("hint = %q, want Node.js install guidance", p.Hint) - } -} - -// --- execute tests (framework runner + fake commandRunner) --- +// --- execute tests (framework runner + fake registry) --- // relAppDevDir returns a relative, cwd-contained, not-yet-existing directory // suitable for --dir (mirrors relCloneDir). @@ -193,112 +413,86 @@ func relAppDevDir(t *testing.T) string { return rel } -func TestAppDevInitAppExecute_DelegatesNpx(t *testing.T) { - f := &fakeCommandRunner{} - withFakeRunner(t, f) +func TestAppDevInitTemplateExecute_RendersFromRegistry(t *testing.T) { + pkg := "@lark-apaas/coding-template-react-standard-webapp" + withFakeRegistry(t, pkg, buildTemplateTgz(t, defaultTemplateEntries())) factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) if err := runAppsShortcut(t, AppsAppDevInitTemplate, []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } - c := findCall(f.calls, "npx", "-y") - if c == nil { - t.Fatalf("npx not invoked: %v", f.calls) - } - if !containsAll(c, "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, - "app", "init", "--template", "react-standard-webapp", "--skip-install") { - t.Errorf("npx args = %v", c) + data := parseEnvelopeData(t, stdout) + if data["dir"] != dir || data["template"] != "react-standard-webapp" || data["version"] != "1.2.3" { + t.Errorf("data = %v", data) } - if containsAll(c, "--app-id") { - t.Errorf("app init must NOT carry --app-id in artifact-hosting mode: %v", c) + if data["files"] != float64(5) { + t.Errorf("files = %v", data["files"]) } - if c[0] != dir { - t.Errorf("npx cwd = %q, want %q", c[0], dir) + // Rendered content on disk. + b, err := os.ReadFile(filepath.Join(dir, "index.html")) + if err != nil || !strings.Contains(string(b), dir) { + t.Errorf("index.html placeholder = %q err=%v (projectName is dir basename)", b, err) } - data := parseEnvelopeData(t, stdout) - if data["dir"] != dir || data["template"] != "react-standard-webapp" { - t.Errorf("data = %v", data) + // meta.json written by lark-cli. + mb, err := os.ReadFile(filepath.Join(dir, metaRelPath)) + if err != nil { + t.Fatal(err) } - if data["stack"] != "react-standard-webapp" { - t.Errorf("stack fallback = %v, want template name", data["stack"]) + var meta map[string]interface{} + _ = json.Unmarshal(mb, &meta) + if meta["stack"] != "react-standard-webapp" || meta["version"] != "1.2.3" || meta["archType"] != float64(2) { + t.Errorf("meta = %v", meta) } steps, _ := data["next_steps"].([]interface{}) if len(steps) != 3 { - t.Errorf("next_steps = %v, want 3 entries", data["next_steps"]) + t.Errorf("next_steps = %v", data["next_steps"]) } } -func TestAppDevInitAppExecute_FullStackTemplate(t *testing.T) { - f := &fakeCommandRunner{} - withFakeRunner(t, f) +func TestAppDevInitTemplateExecute_FullStackPackage(t *testing.T) { + pkg := "@lark-apaas/coding-template-react-express-standard-fullstack" + withFakeRegistry(t, pkg, buildTemplateTgz(t, []tgzEntry{ + {name: "package/package.json", body: `{"miaodaTemplate":{"archType":1}}`}, + {name: "package/template/index.html", body: "fs"}, + })) factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) if err := runAppsShortcut(t, AppsAppDevInitTemplate, []string{"+app-dev-init-template", "--type", "full_stack", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } - if c := findCall(f.calls, "npx", "-y"); c == nil || !containsAll(c, "--template", "react-express-standard-fullstack") { - t.Errorf("full_stack template not passed: %v", f.calls) - } -} - -func TestAppDevInitAppExecute_MetaStackEcho(t *testing.T) { - dir := relAppDevDir(t) - f := &fakeCommandRunner{results: map[string]fakeCallResult{}} - // Simulate the template producing .spark/meta.json during scaffold. - f.results["npx -y"] = fakeCallResult{} - withFakeRunner(t, f) - factory, stdout, _ := newAppsExecuteFactory(t) - // Pre-create meta.json via a side channel: the fake runner records but - // does not write files, so write it before Execute reads it back — the - // dir must stay empty for ensureAppDevDirUsable, so use a wrapper runner. - wrapped := &metaWritingRunner{inner: f, dir: dir, stack: "custom-stack"} - initRunner = wrapped - if err := runAppsShortcut(t, AppsAppDevInitTemplate, - []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { - t.Fatalf("unexpected: %v", err) - } data := parseEnvelopeData(t, stdout) - if data["stack"] != "custom-stack" { - t.Errorf("stack = %v, want custom-stack (from meta.json)", data["stack"]) + if data["template"] != "react-express-standard-fullstack" { + t.Errorf("template = %v", data["template"]) } } -// metaWritingRunner simulates miaoda-cli writing .spark/meta.json into the -// scaffold dir as a side effect of app init. -type metaWritingRunner struct { - inner *fakeCommandRunner - dir string - stack string -} +func TestAppDevInitTemplateExecute_RegistryDown(t *testing.T) { + pkg := "@lark-apaas/coding-template-react-standard-webapp" + mux := http.NewServeMux() + mux.HandleFunc("/"+pkg, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }) + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + origBase, origClient := appDevRegistryBase, appDevNewTransferClient + appDevRegistryBase = srv.URL + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevRegistryBase, appDevNewTransferClient = origBase, origClient }) -func (m *metaWritingRunner) Run(ctx context.Context, dir, name string, args ...string) (string, string, error) { - if err := os.MkdirAll(filepath.Join(m.dir, ".spark"), 0o755); err != nil { - return "", "", err - } - if err := os.WriteFile(filepath.Join(m.dir, metaRelPath), []byte(`{"stack":"`+m.stack+`"}`), 0o644); err != nil { - return "", "", err - } - return m.inner.Run(ctx, dir, name, args...) -} - -func TestAppDevInitAppExecute_NpxFails(t *testing.T) { - f := &fakeCommandRunner{results: map[string]fakeCallResult{ - "npx -y": {stderr: "boom", err: errors.New("exit 1")}, - }} - withFakeRunner(t, f) factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) err := runAppsShortcut(t, AppsAppDevInitTemplate, []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) - p := requireAppsProblem(t, err, errs.CategoryInternal) - if !strings.Contains(p.Message, "npx app init failed") || !strings.Contains(p.Message, "boom") { - t.Errorf("message = %q", p.Message) + p := requireAppsProblem(t, err, errs.CategoryNetwork) + if !p.Retryable { + t.Error("registry 5xx must be retryable") + } + if _, statErr := os.Stat(dir); !os.IsNotExist(statErr) { + t.Error("target dir must not be created when the fetch fails") } } -func TestAppDevInitAppExecute_DirNotEmpty(t *testing.T) { +func TestAppDevInitTemplateExecute_DirNotEmpty(t *testing.T) { dir := relAppDevDir(t) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) @@ -306,8 +500,6 @@ func TestAppDevInitAppExecute_DirNotEmpty(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "x.txt"), []byte("x"), 0o644); err != nil { t.Fatal(err) } - f := &fakeCommandRunner{} - withFakeRunner(t, f) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsAppDevInitTemplate, []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) @@ -315,12 +507,9 @@ func TestAppDevInitAppExecute_DirNotEmpty(t *testing.T) { if p.Subtype != errs.SubtypeFailedPrecondition { t.Errorf("subtype = %q", p.Subtype) } - if len(f.calls) != 0 { - t.Errorf("npx must not run when dir is not empty: %v", f.calls) - } } -func TestAppDevInitAppDryRun(t *testing.T) { +func TestAppDevInitTemplateDryRun(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsAppDevInitTemplate, []string{"+app-dev-init-template", "--type", "frontend", "--as", "user", "--dry-run"}, factory, stdout); err != nil { @@ -330,22 +519,18 @@ func TestAppDevInitAppDryRun(t *testing.T) { if err != nil { t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String()) } - cmdLine, _ := data["command"].(string) - if !strings.Contains(cmdLine, "app init --template react-standard-webapp --skip-install") { - t.Errorf("command = %q", cmdLine) + if data["template_package"] != "@lark-apaas/coding-template-react-standard-webapp" { + t.Errorf("template_package = %v", data["template_package"]) } - if data["remote_side_effects"] != "none (local scaffold via npx)" { + if data["remote_side_effects"] != "read-only npm registry download, no Lark API" { t.Errorf("remote_side_effects = %v", data["remote_side_effects"]) } - if data["target_dir"] != filepath.Join(".", "react-standard-webapp") { - t.Errorf("target_dir = %v", data["target_dir"]) - } if data["target_dir_state"] != "ok (absent or empty)" { t.Errorf("target_dir_state = %v", data["target_dir_state"]) } } -func TestAppDevInitAppDryRun_DirNotEmptySurfaced(t *testing.T) { +func TestAppDevInitTemplateDryRun_DirNotEmptySurfaced(t *testing.T) { dir := relAppDevDir(t) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-template.md b/skills/lark-apps/references/lark-apps-app-dev-init-template.md index 29d61c0532..e4ab50a162 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-init-template.md +++ b/skills/lark-apps/references/lark-apps-app-dev-init-template.md @@ -10,7 +10,7 @@ - 必填:`--type`,取值 `frontend`(映射模板 react-standard-webapp)或 `full_stack`(映射 react-express-standard-fullstack)。 - 可选:`--dir`,相对路径,默认 `./<模板名>`;目录已存在且非空会被拒绝。 -- 前置:已完成 `lark-cli config init`(框架级要求,纯本地命令也需要);本机需有 Node.js(提供 npx)。内部执行 `npx @lark-apaas/miaoda-cli app init --template <模板名> --skip-install` 完成 scaffold,默认不装依赖(秒级返回)。 +- 前置:已完成 `lark-cli config init`(框架级要求,纯本地命令也需要);本步骤**不需要 Node.js**。内部从 npm registry(registry.npmmirror.com)只读下载模板包 `@lark-apaas/coding-template-<模板名>` 并本地渲染,不执行任何远程脚本、不装依赖(秒级返回)。 ## 示例 @@ -29,6 +29,6 @@ lark-cli apps +app-dev-init-template --type full_stack --dry-run ## 常见失败 -- `npx executable not found on PATH`:本机没装 Node.js,转述 hint 让用户安装。 - `target directory ... already exists and is not empty`:换 `--dir` 或让用户清空目录;不要擅自删除已有内容。 -- `npx app init failed`:多为网络或 registry 问题,转述 stderr 摘要;模板 registry 是 registry.npmmirror.com。 +- `npm registry returned 404`:模板包可能未发布,转述 hint(联系产物侧或检查网络/registry 可达性)。 +- registry 5xx / 网络失败:错误带 retryable,可稍后重试。 From b5ad05ad0ff61316552decd1c17c42cff3e65591 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 12:40:14 +0800 Subject: [PATCH 10/51] feat(apps): fall back to the official npm registry and harden extract cap --- shortcuts/apps/app_dev_template_fetch.go | 79 +++++++++-- shortcuts/apps/apps_app_dev_init_template.go | 14 +- .../apps/apps_app_dev_init_template_test.go | 130 +++++++++++++++--- 3 files changed, 185 insertions(+), 38 deletions(-) diff --git a/shortcuts/apps/app_dev_template_fetch.go b/shortcuts/apps/app_dev_template_fetch.go index d9f63b7ec5..5b92a4f544 100644 --- a/shortcuts/apps/app_dev_template_fetch.go +++ b/shortcuts/apps/app_dev_template_fetch.go @@ -30,9 +30,12 @@ const appDevTemplatePkgPrefix = "@lark-apaas/coding-template-" // renderable template files (npm tarballs root at "package/"). const appDevTemplateEntryPrefix = "package/template/" -// appDevRegistryBase is the npm registry used to resolve template packages. -// Package-level var so unit tests can point it at an httptest server. -var appDevRegistryBase = npmRegistry +// appDevRegistries are the npm registries used to resolve template packages, +// tried in order: npmmirror first (fast inside CN), the official registry as +// fallback — a freshly published package may not have synced to the mirror +// yet, and mirror outages must not block scaffolding. Package-level var so +// unit tests can point it at httptest servers. +var appDevRegistries = []string{npmRegistry, "https://registry.npmjs.org"} // Decompression-bomb / runaway-template caps. Vars (not consts) so unit tests // can shrink them to cover the rejection paths; defaults are far above any @@ -59,10 +62,39 @@ type npmPackageMeta struct { } `json:"versions"` } +// fetchAppDevTemplate resolves and downloads the template package, trying +// each registry in appDevRegistries until one succeeds. onFallback is called +// with a human-readable note before each retry (nil to skip). +func fetchAppDevTemplate(ctx context.Context, pkg string, onFallback func(note string)) (version string, tgz []byte, err error) { + var lastErr error + for i, base := range appDevRegistries { + if i > 0 && onFallback != nil { + onFallback(strings.TrimRight(appDevRegistries[i-1], "/") + " failed, falling back to " + strings.TrimRight(base, "/")) + } + v, tarballURL, err := fetchAppDevTemplateMeta(ctx, base, pkg) + if err != nil { + lastErr = err + continue + } + body, err := appDevHTTPGet(ctx, tarballURL, appDevMaxTemplateTgzBytes, + "the template tarball is missing on the registry; contact the artifact team") + if err != nil { + lastErr = err + continue + } + return v, body, nil + } + if p, ok := errs.ProblemOf(lastErr); ok && strings.TrimSpace(p.Hint) == "" { + p.Hint = "all registries failed (" + strings.Join(appDevRegistries, ", ") + "); check network access and whether the template package is published" + } + return "", nil, lastErr +} + // fetchAppDevTemplateMeta resolves the template package's latest version and -// tarball URL from the npm registry. Only https tarball URLs are accepted. -func fetchAppDevTemplateMeta(ctx context.Context, pkg string) (version, tarballURL string, err error) { - metaURL := strings.TrimRight(appDevRegistryBase, "/") + "/" + pkg +// tarball URL from one npm registry. Only https tarball URLs on the same +// registry host are accepted. +func fetchAppDevTemplateMeta(ctx context.Context, registryBase, pkg string) (version, tarballURL string, err error) { + metaURL := strings.TrimRight(registryBase, "/") + "/" + pkg body, err := appDevHTTPGet(ctx, metaURL, appDevMaxTemplateTgzBytes, "the template package may not be published yet; ask the artifact team, or check network/registry access") if err != nil { @@ -87,7 +119,7 @@ func fetchAppDevTemplateMeta(ctx context.Context, pkg string) (version, tarballU // Same-origin constraint: npm registries serve tarballs from the registry // host itself, so a cross-host URL in the metadata is a red flag (metadata // tampering / registry compromise) — refuse rather than follow it. - if reg, rerr := url.Parse(appDevRegistryBase); rerr != nil || u.Host != reg.Host { + if reg, rerr := url.Parse(registryBase); rerr != nil || u.Host != reg.Host { return "", "", appsSubprocessEnvelopeError("npm registry tarball URL host %q differs from registry host; refusing to download", u.Host) } return latest, v.Dist.Tarball, nil @@ -165,8 +197,11 @@ func renderAppDevTemplate(targetDir, projectName string, tgz []byte) (*renderedT return nil, appsSubprocessEnvelopeError("template tarball is not gzip: %v", err) } defer gz.Close() - tr := tar.NewReader(gz) - var total int64 + // Count EVERY decompressed byte (headers, skipped entries, extracted + // data) so a gzip bomb hiding in entries the walk skips still trips the + // cap — the tar reader "skips" by reading through this counter. + counted := &countingReader{r: gz} + tr := tar.NewReader(counted) var pkgJSONRaw []byte files := 0 for { @@ -177,6 +212,10 @@ func renderAppDevTemplate(targetDir, projectName string, tgz []byte) (*renderedT if err != nil { return nil, appsSubprocessEnvelopeError("read template tarball: %v", err) } + if counted.n > appDevMaxTemplateExtractBytes { + return nil, appsValidationError("template extraction exceeds %d bytes limit", appDevMaxTemplateExtractBytes). + WithHint("the template package looks malformed; contact the artifact team") + } raw := strings.TrimPrefix(hdr.Name, "./") // Fail closed on the RAW entry name before any cleaning: a template // carrying traversal or backslash entries is malformed or malicious, @@ -221,18 +260,20 @@ func renderAppDevTemplate(targetDir, projectName string, tgz []byte) (*renderedT if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard); targetDir is validated relative-only. return nil, appsFileIOError(err, "create template directory for %s failed: %v", rel, err) } - remaining := appDevMaxTemplateExtractBytes - total + remaining := appDevMaxTemplateExtractBytes - counted.n + if remaining < 0 { + remaining = 0 + } out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) //nolint:forbidigo // see above. if err != nil { return nil, appsFileIOError(err, "create template file %s failed: %v", rel, err) } - n, err := io.Copy(out, io.LimitReader(tr, remaining+1)) + _, err = io.Copy(out, io.LimitReader(tr, remaining+1)) out.Close() if err != nil { return nil, appsFileIOError(err, "write template file %s failed: %v", rel, err) } - total += n - if total > appDevMaxTemplateExtractBytes { + if counted.n > appDevMaxTemplateExtractBytes { return nil, appsValidationError("template extraction exceeds %d bytes limit", appDevMaxTemplateExtractBytes). WithHint("the template package looks malformed; contact the artifact team") } @@ -270,6 +311,18 @@ func renderAppDevTemplate(targetDir, projectName string, tgz []byte) (*renderedT return rendered, nil } +// countingReader counts bytes read through it (decompressed tar stream). +type countingReader struct { + r io.Reader + n int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n += int64(n) + return n, err +} + // writeAppDevSparkMeta merge-writes {stack, version, archType} into // /.spark/meta.json, creating the directory as needed. Field names align // with miaoda-cli's SparkMeta so downstream tooling reads one format. diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_app_dev_init_template.go index 381d79062a..e93a01aa84 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -125,7 +125,10 @@ var AppsAppDevInitTemplate = common.Shortcut{ dry := common.NewDryRunAPI(). Desc("Scaffold a local web app project by downloading an npm template package (read-only registry fetch, no Lark API)") dry.Set("template_package", pkg) - dry.Set("registry_url", strings.TrimRight(appDevRegistryBase, "/")+"/"+pkg) + dry.Set("registry_url", strings.TrimRight(appDevRegistries[0], "/")+"/"+pkg) + if len(appDevRegistries) > 1 { + dry.Set("registry_fallback", strings.Join(appDevRegistries[1:], ", ")) + } dry.Set("target_dir", dir) dry.Set("template", template) // Surface the same precondition the real run enforces, so a dry-run @@ -146,12 +149,9 @@ var AppsAppDevInitTemplate = common.Shortcut{ } pkg := appDevTemplatePackageName(template) fmt.Fprintf(rctx.IO().ErrOut, "fetching template package %s...\n", pkg) - version, tarballURL, err := fetchAppDevTemplateMeta(ctx, pkg) - if err != nil { - return err - } - tgz, err := appDevHTTPGet(ctx, tarballURL, appDevMaxTemplateTgzBytes, - "the template tarball is missing on the registry; contact the artifact team") + version, tgz, err := fetchAppDevTemplate(ctx, pkg, func(note string) { + fmt.Fprintf(rctx.IO().ErrOut, "registry %s\n", note) + }) if err != nil { return err } diff --git a/shortcuts/apps/apps_app_dev_init_template_test.go b/shortcuts/apps/apps_app_dev_init_template_test.go index 91a8afebeb..4ec9f4f00e 100644 --- a/shortcuts/apps/apps_app_dev_init_template_test.go +++ b/shortcuts/apps/apps_app_dev_init_template_test.go @@ -172,10 +172,10 @@ func withFakeRegistry(t *testing.T, pkg string, tgz []byte) *httptest.Server { }) srv = httptest.NewTLSServer(mux) t.Cleanup(srv.Close) - origBase, origClient := appDevRegistryBase, appDevNewTransferClient - appDevRegistryBase = srv.URL + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL} appDevNewTransferClient = func() *http.Client { return srv.Client() } - t.Cleanup(func() { appDevRegistryBase, appDevNewTransferClient = origBase, origClient }) + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) return srv } @@ -302,12 +302,12 @@ func TestFetchAppDevTemplateMeta_RejectsNonHTTPSTarball(t *testing.T) { }) srv := httptest.NewTLSServer(mux) t.Cleanup(srv.Close) - origBase, origClient := appDevRegistryBase, appDevNewTransferClient - appDevRegistryBase = srv.URL + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL} appDevNewTransferClient = func() *http.Client { return srv.Client() } - t.Cleanup(func() { appDevRegistryBase, appDevNewTransferClient = origBase, origClient }) + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) - _, _, err := fetchAppDevTemplateMeta(context.Background(), pkg) + _, _, err := fetchAppDevTemplateMeta(context.Background(), srv.URL, pkg) if err == nil || !strings.Contains(err.Error(), "not https") { t.Errorf("non-https tarball must be rejected, got %v", err) } @@ -321,12 +321,12 @@ func TestFetchAppDevTemplateMeta_RejectsCrossHostTarball(t *testing.T) { }) srv := httptest.NewTLSServer(mux) t.Cleanup(srv.Close) - origBase, origClient := appDevRegistryBase, appDevNewTransferClient - appDevRegistryBase = srv.URL + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL} appDevNewTransferClient = func() *http.Client { return srv.Client() } - t.Cleanup(func() { appDevRegistryBase, appDevNewTransferClient = origBase, origClient }) + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) - _, _, err := fetchAppDevTemplateMeta(context.Background(), pkg) + _, _, err := fetchAppDevTemplateMeta(context.Background(), srv.URL, pkg) if err == nil || !strings.Contains(err.Error(), "differs from registry host") { t.Errorf("cross-host tarball must be rejected, got %v", err) } @@ -344,18 +344,112 @@ func TestRenderAppDevTemplate_RejectsBackslashEntry(t *testing.T) { func TestFetchAppDevTemplateMeta_404(t *testing.T) { srv := httptest.NewTLSServer(http.NotFoundHandler()) t.Cleanup(srv.Close) - origBase, origClient := appDevRegistryBase, appDevNewTransferClient - appDevRegistryBase = srv.URL + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL} appDevNewTransferClient = func() *http.Client { return srv.Client() } - t.Cleanup(func() { appDevRegistryBase, appDevNewTransferClient = origBase, origClient }) + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) - _, _, err := fetchAppDevTemplateMeta(context.Background(), "@lark-apaas/coding-template-x") + _, _, err := fetchAppDevTemplateMeta(context.Background(), srv.URL, "@lark-apaas/coding-template-x") p := requireAppsProblem(t, err, errs.CategoryNetwork) if !strings.Contains(p.Hint, "not be published") { t.Errorf("404 hint = %q", p.Hint) } } +// --- registry fallback tests --- + +// newFailingThenOKRegistries starts two TLS servers: the first responds with +// failStatus for everything, the second serves pkg + tarball normally, and +// wires appDevRegistries = [failing, ok]. +func newFailingThenOKRegistries(t *testing.T, pkg string, tgz []byte, failStatus int) { + t.Helper() + failing := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(failStatus) + })) + t.Cleanup(failing.Close) + mux := http.NewServeMux() + var okSrv *httptest.Server + mux.HandleFunc("/"+pkg, func(w http.ResponseWriter, _ *http.Request) { + meta := map[string]interface{}{ + "dist-tags": map[string]string{"latest": "1.2.3"}, + "versions": map[string]interface{}{ + "1.2.3": map[string]interface{}{ + "dist": map[string]string{"tarball": okSrv.URL + "/tarball.tgz"}, + }, + }, + } + _ = json.NewEncoder(w).Encode(meta) + }) + mux.HandleFunc("/tarball.tgz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(tgz) }) + okSrv = httptest.NewTLSServer(mux) + t.Cleanup(okSrv.Close) + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{failing.URL, okSrv.URL} + appDevNewTransferClient = func() *http.Client { return okSrv.Client() } + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) +} + +func TestFetchAppDevTemplate_FallbackOn5xx(t *testing.T) { + pkg := "@lark-apaas/coding-template-react-standard-webapp" + newFailingThenOKRegistries(t, pkg, buildTemplateTgz(t, defaultTemplateEntries()), 503) + var notes []string + version, tgz, err := fetchAppDevTemplate(context.Background(), pkg, func(n string) { notes = append(notes, n) }) + if err != nil { + t.Fatalf("fallback should succeed: %v", err) + } + if version != "1.2.3" || len(tgz) == 0 { + t.Errorf("version=%q len=%d", version, len(tgz)) + } + if len(notes) != 1 || !strings.Contains(notes[0], "falling back to") { + t.Errorf("fallback note = %v", notes) + } +} + +func TestFetchAppDevTemplate_FallbackOn404(t *testing.T) { + // A freshly published package may not have synced to the mirror yet — + // 404 on the primary must also fall through to the official registry. + pkg := "@lark-apaas/coding-template-react-standard-webapp" + newFailingThenOKRegistries(t, pkg, buildTemplateTgz(t, defaultTemplateEntries()), 404) + version, _, err := fetchAppDevTemplate(context.Background(), pkg, nil) + if err != nil || version != "1.2.3" { + t.Errorf("404 fallback: version=%q err=%v", version, err) + } +} + +func TestFetchAppDevTemplate_AllRegistriesFail(t *testing.T) { + srv := httptest.NewTLSServer(http.NotFoundHandler()) + t.Cleanup(srv.Close) + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL, srv.URL} + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) + + _, _, err := fetchAppDevTemplate(context.Background(), "@lark-apaas/coding-template-x", nil) + if err == nil { + t.Fatal("all-fail must error") + } + p, _ := errs.ProblemOf(err) + if p == nil || !strings.Contains(p.Hint, "not be published") { + t.Errorf("hint = %v", p) + } +} + +func TestRenderAppDevTemplate_SkippedEntryBombCap(t *testing.T) { + // A huge entry OUTSIDE package/template/ is skipped by the walk, but its + // decompressed bytes still stream through the counter and must trip the + // cap (gzip-bomb defense for skipped entries). + orig := appDevMaxTemplateExtractBytes + appDevMaxTemplateExtractBytes = 64 + t.Cleanup(func() { appDevMaxTemplateExtractBytes = orig }) + tgz := buildTemplateTgz(t, []tgzEntry{ + {name: "package/ignored-bomb.bin", body: strings.Repeat("0", 4096)}, + {name: "package/template/index.html", body: "ok"}, + }) + if _, err := renderAppDevTemplate(t.TempDir(), "p", tgz); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Errorf("skipped-entry bomb must trip the cap, got %v", err) + } +} + // --- declaration & validate tests --- func TestAppsAppDevInitTemplate_Declaration(t *testing.T) { @@ -474,10 +568,10 @@ func TestAppDevInitTemplateExecute_RegistryDown(t *testing.T) { mux.HandleFunc("/"+pkg, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }) srv := httptest.NewTLSServer(mux) t.Cleanup(srv.Close) - origBase, origClient := appDevRegistryBase, appDevNewTransferClient - appDevRegistryBase = srv.URL + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL} appDevNewTransferClient = func() *http.Client { return srv.Client() } - t.Cleanup(func() { appDevRegistryBase, appDevNewTransferClient = origBase, origClient }) + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) From 474aeaecad46da39694b9ce091405e7eb87aa8d9 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 12:40:14 +0800 Subject: [PATCH 11/51] docs(apps): note registry fallback in init-template reference --- .../lark-apps/references/lark-apps-app-dev-init-template.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-template.md b/skills/lark-apps/references/lark-apps-app-dev-init-template.md index e4ab50a162..bf280350a0 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-init-template.md +++ b/skills/lark-apps/references/lark-apps-app-dev-init-template.md @@ -10,7 +10,7 @@ - 必填:`--type`,取值 `frontend`(映射模板 react-standard-webapp)或 `full_stack`(映射 react-express-standard-fullstack)。 - 可选:`--dir`,相对路径,默认 `./<模板名>`;目录已存在且非空会被拒绝。 -- 前置:已完成 `lark-cli config init`(框架级要求,纯本地命令也需要);本步骤**不需要 Node.js**。内部从 npm registry(registry.npmmirror.com)只读下载模板包 `@lark-apaas/coding-template-<模板名>` 并本地渲染,不执行任何远程脚本、不装依赖(秒级返回)。 +- 前置:已完成 `lark-cli config init`(框架级要求,纯本地命令也需要);本步骤**不需要 Node.js**。内部从 npm registry 只读下载模板包(主源 registry.npmmirror.com,失败自动降级 registry.npmjs.org 官方源) `@lark-apaas/coding-template-<模板名>` 并本地渲染,不执行任何远程脚本、不装依赖(秒级返回)。 ## 示例 @@ -30,5 +30,5 @@ lark-cli apps +app-dev-init-template --type full_stack --dry-run ## 常见失败 - `target directory ... already exists and is not empty`:换 `--dir` 或让用户清空目录;不要擅自删除已有内容。 -- `npm registry returned 404`:模板包可能未发布,转述 hint(联系产物侧或检查网络/registry 可达性)。 +- `npm registry returned 404`:主源与官方源都取不到时报出,模板包可能未发布,转述 hint(联系产物侧或检查网络/registry 可达性)。 - registry 5xx / 网络失败:错误带 retryable,可稍后重试。 From 4fda3604f1268c205d6a20207946011f6438029b Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 14:40:08 +0800 Subject: [PATCH 12/51] feat(apps): support explicit --template for init-template --- shortcuts/apps/apps_app_dev_init_template.go | 45 ++++++++++++++++--- .../apps/apps_app_dev_init_template_test.go | 45 ++++++++++++++++++- .../lark-apps-app-dev-init-template.md | 3 +- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_app_dev_init_template.go index e93a01aa84..bde3a5d853 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "github.com/larksuite/cli/shortcuts/common" @@ -40,6 +41,33 @@ func appDevTemplateForType(appType string) string { return "" } +// appDevTemplateNameRe constrains an explicit --template short name to the +// npm package-name-segment charset. The value is spliced into the registry +// URL and the package name, so anything looser (slashes, "..", "@") would +// change the URL/package semantics. +var appDevTemplateNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,99}$`) + +// resolveAppDevTemplate picks the template short name: an explicit --template +// wins (template-first, mirroring miaoda-cli's resolveStack), otherwise +// --type is mapped through appDevTemplateForType. There is deliberately no +// allowlist for --template — new template packages ship without CLI changes. +func resolveAppDevTemplate(rctx *common.RuntimeContext) (string, error) { + if tpl := strings.TrimSpace(rctx.Str("template")); tpl != "" { + if !appDevTemplateNameRe.MatchString(tpl) { + return "", appsValidationParamError("--template", + "--template must be an npm package name segment (lowercase letters, digits, '.', '_', '-'), got %q", tpl). + WithHint("pass the template short name, e.g. react-standard-webapp; it resolves to " + appDevTemplatePkgPrefix + "") + } + return tpl, nil + } + appType := strings.TrimSpace(rctx.Str("type")) + if appType == "" { + return "", appsValidationParamError("--type", "--type or --template is required"). + WithHint("pass --type frontend|full_stack for the default templates, or --template to use a specific template package") + } + return appDevTemplateForType(appType), nil +} + // resolveAppDevDir returns the scaffold target directory: --dir when set, // otherwise ./. func resolveAppDevDir(dir, template string) string { @@ -99,6 +127,7 @@ var AppsAppDevInitTemplate = common.Shortcut{ Tips: []string{ "Example: lark-cli apps +app-dev-init-template --type frontend --dir ./my-app", "Example: lark-cli apps +app-dev-init-template --type full_stack --dry-run", + "Example: lark-cli apps +app-dev-init-template --template vite-react --dir ./demo (use a specific template package directly)", "The scaffold is local-only: create the Miaoda app later with +create and deploy with +app-dev-publish", }, // No Lark OAPI is called; explicit []string{} per the convention @@ -107,19 +136,18 @@ var AppsAppDevInitTemplate = common.Shortcut{ AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ - {Name: "type", Desc: "app type; maps to a template package (frontend=react-standard-webapp, full_stack=react-express-standard-fullstack)", Enum: []string{"frontend", "full_stack"}}, + {Name: "type", Desc: "app type; maps to a template package (frontend=react-standard-webapp, full_stack=react-express-standard-fullstack); ignored when --template is set", Enum: []string{"frontend", "full_stack"}}, + {Name: "template", Desc: "template short name to use directly (resolves to @lark-apaas/coding-template-); takes precedence over --type"}, {Name: "dir", Desc: "target directory, relative path (default ./); must be new or empty"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - appType := strings.TrimSpace(rctx.Str("type")) - if appType == "" { - return appsValidationParamError("--type", "--type is required"). - WithHint("valid values: frontend | full_stack") + if _, err := resolveAppDevTemplate(rctx); err != nil { + return err } return validateAppDevDir(rctx.Str("dir")) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - template := appDevTemplateForType(strings.TrimSpace(rctx.Str("type"))) + template, _ := resolveAppDevTemplate(rctx) // Validate already rejected invalid input dir := resolveAppDevDir(rctx.Str("dir"), template) pkg := appDevTemplatePackageName(template) dry := common.NewDryRunAPI(). @@ -142,7 +170,10 @@ var AppsAppDevInitTemplate = common.Shortcut{ return dry }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - template := appDevTemplateForType(strings.TrimSpace(rctx.Str("type"))) + template, err := resolveAppDevTemplate(rctx) + if err != nil { + return err + } dir := resolveAppDevDir(rctx.Str("dir"), template) if err := ensureAppDevDirUsable(dir); err != nil { return err diff --git a/shortcuts/apps/apps_app_dev_init_template_test.go b/shortcuts/apps/apps_app_dev_init_template_test.go index 4ec9f4f00e..db6ee5e746 100644 --- a/shortcuts/apps/apps_app_dev_init_template_test.go +++ b/shortcuts/apps/apps_app_dev_init_template_test.go @@ -471,9 +471,15 @@ func TestAppsAppDevInitTemplate_Declaration(t *testing.T) { } func testRuntimeAppDevInit(t *testing.T, appType, dir string) *common.RuntimeContext { + t.Helper() + return testRuntimeAppDevInitTpl(t, appType, "", dir) +} + +func testRuntimeAppDevInitTpl(t *testing.T, appType, template, dir string) *common.RuntimeContext { t.Helper() cmd := &cobra.Command{Use: "+app-dev-init-template"} cmd.Flags().String("type", appType, "") + cmd.Flags().String("template", template, "") cmd.Flags().String("dir", dir, "") return common.TestNewRuntimeContext(cmd, nil) } @@ -482,7 +488,7 @@ func TestAppDevInitTemplateValidate(t *testing.T) { tests := []struct { name, appType, dir, wantErr string }{ - {"missing type", "", "", "--type is required"}, + {"missing type and template", "", "", "--type or --template is required"}, {"abs dir", "frontend", "/abs", "--dir"}, {"dotdot dir", "frontend", "../x", "--dir"}, } @@ -496,6 +502,25 @@ func TestAppDevInitTemplateValidate(t *testing.T) { } } +func TestResolveAppDevTemplate(t *testing.T) { + // template-first: explicit --template wins over --type. + tpl, err := resolveAppDevTemplate(testRuntimeAppDevInitTpl(t, "frontend", "vite-react", "")) + if err != nil || tpl != "vite-react" { + t.Errorf("template-first: got (%q, %v)", tpl, err) + } + // --type mapping still works without --template. + tpl, err = resolveAppDevTemplate(testRuntimeAppDevInitTpl(t, "full_stack", "", "")) + if err != nil || tpl != "react-express-standard-fullstack" { + t.Errorf("type mapping: got (%q, %v)", tpl, err) + } + // Unsafe template names are rejected (they splice into URL/package name). + for _, bad := range []string{"../evil", "a/b", "@scope/x", "UPPER", "-lead", "x y"} { + if _, err := resolveAppDevTemplate(testRuntimeAppDevInitTpl(t, "", bad, "")); err == nil { + t.Errorf("template %q should be rejected", bad) + } + } +} + // --- execute tests (framework runner + fake registry) --- // relAppDevDir returns a relative, cwd-contained, not-yet-existing directory @@ -562,6 +587,24 @@ func TestAppDevInitTemplateExecute_FullStackPackage(t *testing.T) { } } +func TestAppDevInitTemplateExecute_ExplicitTemplate(t *testing.T) { + pkg := "@lark-apaas/coding-template-vite-react" + withFakeRegistry(t, pkg, buildTemplateTgz(t, []tgzEntry{ + {name: "package/package.json", body: `{"miaodaTemplate":{"archType":2}}`}, + {name: "package/template/index.html", body: "tpl"}, + })) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relAppDevDir(t) + if err := runAppsShortcut(t, AppsAppDevInitTemplate, + []string{"+app-dev-init-template", "--template", "vite-react", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["template"] != "vite-react" || data["stack"] != "vite-react" { + t.Errorf("data = %v", data) + } +} + func TestAppDevInitTemplateExecute_RegistryDown(t *testing.T) { pkg := "@lark-apaas/coding-template-react-standard-webapp" mux := http.NewServeMux() diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-template.md b/skills/lark-apps/references/lark-apps-app-dev-init-template.md index bf280350a0..2fd288c601 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-init-template.md +++ b/skills/lark-apps/references/lark-apps-app-dev-init-template.md @@ -8,7 +8,7 @@ ## 命令骨架 -- 必填:`--type`,取值 `frontend`(映射模板 react-standard-webapp)或 `full_stack`(映射 react-express-standard-fullstack)。 +- `--type` 与 `--template` 二选一:`--type frontend|full_stack` 用默认模板映射;`--template <短名>`(如 `vite-react`)直接指定模板包,优先于 `--type`——模板包名为 `@lark-apaas/coding-template-<短名>`。 - 可选:`--dir`,相对路径,默认 `./<模板名>`;目录已存在且非空会被拒绝。 - 前置:已完成 `lark-cli config init`(框架级要求,纯本地命令也需要);本步骤**不需要 Node.js**。内部从 npm registry 只读下载模板包(主源 registry.npmmirror.com,失败自动降级 registry.npmjs.org 官方源) `@lark-apaas/coding-template-<模板名>` 并本地渲染,不执行任何远程脚本、不装依赖(秒级返回)。 @@ -16,6 +16,7 @@ ```bash lark-cli apps +app-dev-init-template --type frontend --dir ./my-app +lark-cli apps +app-dev-init-template --template vite-react --dir ./demo lark-cli apps +app-dev-init-template --type full_stack --dry-run ``` From 89ed8f6306f25cd06ce5f8cdf2203d336aec9dc4 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 14:53:58 +0800 Subject: [PATCH 13/51] feat(apps): add optional --app-id to publish and drop archType --- shortcuts/apps/app_dev_template_fetch.go | 36 +------ shortcuts/apps/apps_app_dev_init_template.go | 5 +- .../apps/apps_app_dev_init_template_test.go | 12 +-- shortcuts/apps/apps_app_dev_publish.go | 99 ++++++++++++++----- shortcuts/apps/apps_app_dev_publish_test.go | 56 ++++++++++- .../references/lark-apps-app-dev-publish.md | 8 +- 6 files changed, 142 insertions(+), 74 deletions(-) diff --git a/shortcuts/apps/app_dev_template_fetch.go b/shortcuts/apps/app_dev_template_fetch.go index 5b92a4f544..24584e77e5 100644 --- a/shortcuts/apps/app_dev_template_fetch.go +++ b/shortcuts/apps/app_dev_template_fetch.go @@ -162,16 +162,7 @@ func appDevHTTPGet(ctx context.Context, rawURL string, maxBytes int64, notFoundH // renderedTemplate reports what renderAppDevTemplate materialized. type renderedTemplate struct { - ArchType interface{} - Files int -} - -// templatePkgJSON is the subset of the template package's own package.json -// the renderer reads (archType rides on miaodaTemplate, set by the template). -type templatePkgJSON struct { - MiaodaTemplate struct { - ArchType interface{} `json:"archType"` - } `json:"miaodaTemplate"` + Files int } // renamedTemplateFiles maps placeholder names shipped in the tarball to their @@ -202,7 +193,6 @@ func renderAppDevTemplate(targetDir, projectName string, tgz []byte) (*renderedT // cap — the tar reader "skips" by reading through this counter. counted := &countingReader{r: gz} tr := tar.NewReader(counted) - var pkgJSONRaw []byte files := 0 for { hdr, err := tr.Next() @@ -232,14 +222,6 @@ func renderAppDevTemplate(targetDir, projectName string, tgz []byte) (*renderedT if hdr.Typeflag != tar.TypeReg { continue } - if name == "package/package.json" { - raw, err := io.ReadAll(io.LimitReader(tr, 1<<20)) - if err != nil { - return nil, appsSubprocessEnvelopeError("read template package.json: %v", err) - } - pkgJSONRaw = raw - continue - } if !strings.HasPrefix(name, appDevTemplateEntryPrefix) { continue } @@ -301,14 +283,7 @@ func renderAppDevTemplate(targetDir, projectName string, tgz []byte) (*renderedT } } - rendered := &renderedTemplate{Files: files} - if len(pkgJSONRaw) > 0 { - var pkg templatePkgJSON - if err := json.Unmarshal(pkgJSONRaw, &pkg); err == nil { - rendered.ArchType = pkg.MiaodaTemplate.ArchType - } - } - return rendered, nil + return &renderedTemplate{Files: files}, nil } // countingReader counts bytes read through it (decompressed tar stream). @@ -323,10 +298,10 @@ func (c *countingReader) Read(p []byte) (int, error) { return n, err } -// writeAppDevSparkMeta merge-writes {stack, version, archType} into +// writeAppDevSparkMeta merge-writes {stack, version} into // /.spark/meta.json, creating the directory as needed. Field names align // with miaoda-cli's SparkMeta so downstream tooling reads one format. -func writeAppDevSparkMeta(dir, stack, version string, archType interface{}) error { +func writeAppDevSparkMeta(dir, stack, version string) error { sparkDir := filepath.Join(dir, ".spark") if err := os.MkdirAll(sparkDir, 0o755); err != nil { //nolint:forbidigo // see renderAppDevTemplate. return appsFileIOError(err, "create .spark directory failed: %v", err) @@ -338,9 +313,6 @@ func writeAppDevSparkMeta(dir, stack, version string, archType interface{}) erro } meta["stack"] = stack meta["version"] = version - if archType != nil { - meta["archType"] = archType - } out, err := json.MarshalIndent(meta, "", " ") if err != nil { return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err) diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_app_dev_init_template.go index bde3a5d853..a6d05c8ff0 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -193,10 +193,7 @@ var AppsAppDevInitTemplate = common.Shortcut{ if err != nil { return err } - if rendered.ArchType == nil { - fmt.Fprintf(rctx.IO().ErrOut, "warning: template package %s@%s has no miaodaTemplate.archType; the template should declare it\n", pkg, version) - } - if err := writeAppDevSparkMeta(dir, template, version, rendered.ArchType); err != nil { + if err := writeAppDevSparkMeta(dir, template, version); err != nil { return err } nextSteps := []string{ diff --git a/shortcuts/apps/apps_app_dev_init_template_test.go b/shortcuts/apps/apps_app_dev_init_template_test.go index db6ee5e746..9cd6fe3539 100644 --- a/shortcuts/apps/apps_app_dev_init_template_test.go +++ b/shortcuts/apps/apps_app_dev_init_template_test.go @@ -190,9 +190,6 @@ func TestRenderAppDevTemplate(t *testing.T) { if rendered.Files != 5 { t.Errorf("Files = %d, want 5 (template subtree only)", rendered.Files) } - if rendered.ArchType != float64(2) { - t.Errorf("ArchType = %v (%T), want 2", rendered.ArchType, rendered.ArchType) - } // Placeholder replaced. b, _ := os.ReadFile(filepath.Join(dir, "index.html")) if string(b) != "my-app" { @@ -276,7 +273,7 @@ func TestRenderAppDevTemplate_FileCountCap(t *testing.T) { func TestWriteAppDevSparkMeta(t *testing.T) { dir := t.TempDir() - if err := writeAppDevSparkMeta(dir, "react-standard-webapp", "1.2.3", float64(2)); err != nil { + if err := writeAppDevSparkMeta(dir, "react-standard-webapp", "1.2.3"); err != nil { t.Fatal(err) } b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) @@ -287,9 +284,12 @@ func TestWriteAppDevSparkMeta(t *testing.T) { if err := json.Unmarshal(b, &meta); err != nil { t.Fatal(err) } - if meta["stack"] != "react-standard-webapp" || meta["version"] != "1.2.3" || meta["archType"] != float64(2) { + if meta["stack"] != "react-standard-webapp" || meta["version"] != "1.2.3" { t.Errorf("meta = %v", meta) } + if _, has := meta["archType"]; has { + t.Error("meta.json must not carry archType (not part of the contract)") + } } // --- fetch tests --- @@ -560,7 +560,7 @@ func TestAppDevInitTemplateExecute_RendersFromRegistry(t *testing.T) { } var meta map[string]interface{} _ = json.Unmarshal(mb, &meta) - if meta["stack"] != "react-standard-webapp" || meta["version"] != "1.2.3" || meta["archType"] != float64(2) { + if meta["stack"] != "react-standard-webapp" || meta["version"] != "1.2.3" { t.Errorf("meta = %v", meta) } steps, _ := data["next_steps"].([]interface{}) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 2f5ddfa338..9bf43ce338 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -167,6 +167,52 @@ func appDevSensitiveCandidatesError(hits []string) error { WithHint("remove these files from the build output, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)") } +// resolveAppDevPublishAppID resolves the publish target from --app-id and +// .spark/meta.json: +// - flag only -> use it (backfilled into meta.json after a +// successful publish, so retries and later runs need no flag) +// - meta only -> use it (the zero-flag iteration path) +// - both, equal -> fine +// - both, different -> refuse: silently overwriting the recorded +// target could ship the build to the wrong app +// - neither -> guide the user to +create first +// +// fromFlag reports whether the value came from --app-id (drives backfill). +func resolveAppDevPublishAppID(rctx *common.RuntimeContext) (appID string, fromFlag bool, err error) { + flagID := strings.TrimSpace(rctx.Str("app-id")) + metaID, isSpark, err := readMetaAppID(".") + if err != nil { + return "", false, err + } + if !isSpark { + return "", false, appsFailedPreconditionError( + "current directory is not a Miaoda app project (.spark/meta.json not found)"). + WithHint("run this command from the project root; scaffold a project with +app-dev-init-template first") + } + metaID = strings.TrimSpace(metaID) + switch { + case flagID == "" && metaID == "": + return "", false, appsFailedPreconditionError("no publish target: .spark/meta.json has no app_id and --app-id was not given"). + WithHint("create the app first with `lark-cli apps +create --name `, then publish with `lark-cli apps +app-dev-publish --app-id ` (the id is saved into .spark/meta.json on success)") + case flagID != "" && metaID != "" && flagID != metaID: + return "", false, appsFailedPreconditionParamError("--app-id", + ".spark/meta.json already records app_id %s but --app-id is %s; refusing to silently switch the publish target", metaID, flagID). + WithHint("drop --app-id to publish to the recorded app, or update app_id in .spark/meta.json first if you really mean to switch") + case flagID != "": + if err := validateRealAppID(flagID); err != nil { + return "", false, err + } + return flagID, metaID == "", nil + default: + if !strings.HasPrefix(metaID, "app_") { + return "", false, appsFailedPreconditionError( + `.spark/meta.json app_id %q is invalid (must start with "app_")`, metaID). + WithHint("fix app_id in .spark/meta.json: find the right id with `lark-cli apps +list`, or create the app with `lark-cli apps +create --name `") + } + return metaID, false, nil + } +} + // envCommandRunner runs a subprocess with extra environment variables // appended to the parent env. Separate from commandRunner because only the // build step needs env injection, and a dedicated seam keeps init tests and @@ -215,31 +261,14 @@ var AppsAppDevPublish = common.Shortcut{ AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ + {Name: "app-id", Desc: "publish target app ID (app_ prefix); optional when .spark/meta.json already records one — on a successful publish it is saved into .spark/meta.json, and a value conflicting with the recorded one is rejected"}, {Name: "skip-build", Type: "bool", Desc: "skip npm run build and publish the existing ./dist as-is"}, {Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / etc. in the publish payload)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - appID, isSpark, err := readMetaAppID(".") - if err != nil { + if _, _, err := resolveAppDevPublishAppID(rctx); err != nil { return err } - if !isSpark { - return appsFailedPreconditionError( - "current directory is not a Miaoda app project (.spark/meta.json not found)"). - WithHint("run this command from the project root; scaffold a project with +app-dev-init-template first") - } - if strings.TrimSpace(appID) == "" { - return appsFailedPreconditionError(".spark/meta.json has no app_id"). - WithHint("create the app with `lark-cli apps +create --name `, then write the returned app_id into .spark/meta.json") - } - // The app id comes from meta.json, not a flag — a bespoke error here - // instead of validateRealAppID, whose --app-id wording would point the - // user at a flag this command does not have. - if !strings.HasPrefix(appID, "app_") { - return appsFailedPreconditionError( - `.spark/meta.json app_id %q is invalid (must start with "app_")`, appID). - WithHint("fix app_id in .spark/meta.json: find the right id with `lark-cli apps +list`, or create the app with `lark-cli apps +create --name `") - } // Sensitive-file scan lives in Validate so that --dry-run exits // non-zero on a hit — the one deliberate exception to dry-run's // exit-0 convention (mirrors +html-publish). Walk errors (e.g. dist @@ -272,14 +301,17 @@ var AppsAppDevPublish = common.Shortcut{ DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { dry := common.NewDryRunAPI(). Desc("Read .spark/meta.json app_id -> GET pre_release (upload_url/tos_path + MIAODA_* build env) -> npm run build -> validate dist layout -> zip -> PUT to TOS -> POST releases; returns online_url (sync) or release_id (async)") - appID, isSpark, err := readMetaAppID(".") + appID, fromFlag, err := resolveAppDevPublishAppID(rctx) switch { case err != nil: dry.Set("meta_error", err.Error()) - case !isSpark: - dry.Set("meta_error", ".spark/meta.json not found in current directory") default: dry.Set("app_id", appID) + if fromFlag { + dry.Set("app_id_source", "--app-id flag (will be saved into .spark/meta.json on success)") + } else { + dry.Set("app_id_source", ".spark/meta.json") + } dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))). PUT(" (https only, from pre_release kvs)"). POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))). @@ -297,14 +329,18 @@ var AppsAppDevPublish = common.Shortcut{ return dry }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - appID, _, err := readMetaAppID(".") + appID, fromFlag, err := resolveAppDevPublishAppID(rctx) if err != nil { return err } - // meta.json is a tamperable workspace file and the server-side owner - // check is the only authorization line — echo the target loudly so a - // wrong app_id is visible before anything ships. - fmt.Fprintf(rctx.IO().ErrOut, "publishing to app %s (from %s)\n", appID, metaRelPath) + // The server-side owner check is the only authorization line — echo + // the target loudly so a wrong app_id is visible before anything + // ships, naming where the id came from. + source := metaRelPath + if fromFlag { + source = "--app-id" + } + fmt.Fprintf(rctx.IO().ErrOut, "publishing to app %s (from %s)\n", appID, source) // pre_release comes before the build: no point building when the app // is missing or inaccessible, and the build env rides on this response. @@ -370,6 +406,15 @@ var AppsAppDevPublish = common.Shortcut{ return withAppsHint(err, "verify the app supports artifact-hosting publish; list your apps with `lark-cli apps +list`") } + // The release was accepted — persist a flag-provided app_id so later + // runs need no flag ("deploy-time fill-in" per the design doc). Only + // fills a missing app_id; never overwrites (mismatch was rejected in + // Validate). Best-effort: a write failure must not fail the publish. + if fromFlag { + if err := ensureMetaAppID(".", appID); err != nil { + fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to save app_id into %s: %v\n", metaRelPath, err) + } + } releaseID := common.GetString(releaseData, "release_id") status := common.GetString(releaseData, "status") onlineURL := common.GetString(releaseData, "online_url") diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index 1b40d71136..0173a96194 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -321,8 +321,60 @@ func TestAppDevPublishValidate_NoAppID(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) - if !strings.Contains(p.Message, "no app_id") || !strings.Contains(p.Hint, "+create") { - t.Errorf("got %v", p) + if !strings.Contains(p.Message, "no publish target") { + t.Errorf("message = %q", p.Message) + } + // The guidance must lead to +create and the new --app-id flow (no manual + // JSON editing). + if !strings.Contains(p.Hint, "+create") || !strings.Contains(p.Hint, "+app-dev-publish --app-id") { + t.Errorf("hint = %q", p.Hint) + } +} + +func TestAppDevPublishValidate_AppIDMismatch(t *testing.T) { + chdirProjectRoot(t, `{"app_id":"app_recorded"}`) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAppDevPublish, + []string{"+app-dev-publish", "--app-id", "app_other", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if !strings.Contains(p.Message, "app_recorded") || !strings.Contains(p.Message, "app_other") { + t.Errorf("message must name both ids, got %q", p.Message) + } + if !strings.Contains(p.Hint, "drop --app-id") { + t.Errorf("hint = %q", p.Hint) + } +} + +func TestAppDevPublishExecute_FlagAppIDBackfill(t *testing.T) { + root := chdirProjectRoot(t, `{"stack":"react-standard-webapp"}`) // no app_id + writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_flag1", srv.URL, nil) + stubReleases(reg, "app_flag1", map[string]interface{}{"release_id": "rel_9", "status": "pending"}) + if err := runAppsShortcut(t, AppsAppDevPublish, + []string{"+app-dev-publish", "--app-id", "app_flag1", "--skip-build", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + // app_id persisted on success, other fields preserved. + b, _ := os.ReadFile(filepath.Join(root, metaRelPath)) + var meta map[string]interface{} + _ = json.Unmarshal(b, &meta) + if meta["app_id"] != "app_flag1" || meta["stack"] != "react-standard-webapp" { + t.Errorf("meta after publish = %v", meta) + } +} + +func TestAppDevPublishExecute_FlagMatchesMeta(t *testing.T) { + root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_10", "status": "pending"}) + if err := runAppsShortcut(t, AppsAppDevPublish, + []string{"+app-dev-publish", "--app-id", "app_x", "--skip-build", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("matching --app-id must publish fine: %v", err) } } diff --git a/skills/lark-apps/references/lark-apps-app-dev-publish.md b/skills/lark-apps/references/lark-apps-app-dev-publish.md index 23cab3bd1e..c4b78f483f 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-publish.md +++ b/skills/lark-apps/references/lark-apps-app-dev-publish.md @@ -8,7 +8,8 @@ ## 命令骨架 -- **必须在项目根目录执行**:项目根须有 `.spark/meta.json` 且含 `app_id`。命令不接受 `--app-id` / `--path` 参数;产物目录固定为 `./dist`。 +- **必须在项目根目录执行**(项目根须有 `.spark/meta.json`);产物目录固定为 `./dist`,无 `--path` 参数。 +- `--app-id` 可选:首次发布传它指定目标(成功后自动写入 `.spark/meta.json`,后续免传);meta.json 已有 app_id 时可省略;**两者都有且不一致会被拒绝**(防误发错目标),确要切换先更新 meta.json。 - 可选:`--skip-build`(跳过 `npm run build`,直接发布已有 `./dist`)、`--allow-sensitive`(跳过凭据文件扫描)。 - 内部流程:读 meta.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → `npm run build`(自动注入这些变量)→ 校验 dist 产物协议 → zip 上传 → 触发发布。 - 产物协议:`dist/output/` 必须含 `index.html` 与 `routes.json`;`dist/output_resource/` 可选;dist 顶层不允许其他条目。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 @@ -16,7 +17,8 @@ ## 示例 ```bash -lark-cli apps +app-dev-publish +lark-cli apps +app-dev-publish --app-id app_xxx # 首次发布:指定目标,成功后写入 meta.json +lark-cli apps +app-dev-publish # 迭代重发:读 meta.json,零参数 lark-cli apps +app-dev-publish --skip-build lark-cli apps +app-dev-publish --dry-run ``` @@ -29,7 +31,7 @@ lark-cli apps +app-dev-publish --dry-run ## 前置引导 -- meta.json 缺 `app_id` 时:先 `lark-cli apps +create --name ` 创建应用,把返回的 `app_id` 写入 `.spark/meta.json` 再发布;应用名可从项目主题生成,不要让用户手动提供 app_id。 +- meta.json 缺 `app_id` 时:先 `lark-cli apps +create --name ` 创建应用,然后 `lark-cli apps +app-dev-publish --app-id <返回的 app_id>` 发布(成功后 app_id 自动写入 meta.json,无需手工编辑文件);应用名可从项目主题生成,不要让用户手动提供 app_id。 - **`app_id` 不是本会话写入的**(来自历史文件或他人仓库)时,发布前先把目标 `app_id` 告知用户并确认——发布会覆盖该应用的线上内容。 ## 安全规则 From 9eed68ba9dca439cd3e02c4c883e2b571bc55914 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 15:44:34 +0800 Subject: [PATCH 14/51] feat(apps): adopt miaoda.json artifact-hosting protocol --- shortcuts/apps/app_dev_project_config.go | 165 ++++++++++++++++++ shortcuts/apps/app_dev_template_fetch.go | 25 --- shortcuts/apps/apps_app_dev_init_template.go | 6 +- .../apps/apps_app_dev_init_template_test.go | 49 ++++-- shortcuts/apps/apps_app_dev_publish.go | 138 ++++++++------- shortcuts/apps/apps_app_dev_publish_test.go | 111 +++++++++++- 6 files changed, 384 insertions(+), 110 deletions(-) create mode 100644 shortcuts/apps/app_dev_project_config.go diff --git a/shortcuts/apps/app_dev_project_config.go b/shortcuts/apps/app_dev_project_config.go new file mode 100644 index 0000000000..3d5d89e4ed --- /dev/null +++ b/shortcuts/apps/app_dev_project_config.go @@ -0,0 +1,165 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// miaodaJSONRelPath is the project declaration file of the artifact-hosting +// protocol (妙搭产物托管协议规范 §3): how to dev/build, plus the app state +// section written back by the deploy chain. +const miaodaJSONRelPath = "miaoda.json" + +// Protocol defaults (§3 缺省行为): convention first, configuration override. +var ( + appDevDefaultBuildCommand = []string{"npm", "run", "build"} + appDevDefaultBuildOutput = "dist" +) + +// appDevProjectConfig is the resolved view of the project declaration that +// +app-dev-publish consumes. Fields are filled with protocol defaults when +// the declaration omits them. +type appDevProjectConfig struct { + Stack string + Version string + BuildCommand []string + BuildOutput string + AppID string + AppURL string + // Source is the file the config came from: miaodaJSONRelPath or + // metaRelPath (legacy fallback). It decides where the app state is + // written back after a successful publish. + Source string +} + +// miaodaJSONDoc mirrors the miaoda.json schema (§3). Unknown fields are +// ignored on read and preserved on write (the writer re-marshals the raw +// map, not this struct). +type miaodaJSONDoc struct { + Stack string `json:"stack"` + Version string `json:"version"` + Build struct { + Command []string `json:"command"` + Output string `json:"output"` + } `json:"build"` + App struct { + ID string `json:"id"` + URL string `json:"url"` + } `json:"app"` +} + +// readAppDevProjectConfig loads the project declaration from dir: +// miaoda.json first, falling back to the legacy .spark/meta.json (cloud +// sandbox form, kept untouched per §3). found=false means neither exists — +// the directory is not a Miaoda app project. +func readAppDevProjectConfig(dir string) (cfg *appDevProjectConfig, found bool, err error) { + mp := filepath.Join(dir, miaodaJSONRelPath) + if b, rerr := os.ReadFile(mp); rerr == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard); path is cwd-relative. + var doc miaodaJSONDoc + if jerr := json.Unmarshal(b, &doc); jerr != nil { + return nil, true, appsFileIOError(jerr, "parse %s failed: %v", miaodaJSONRelPath, jerr) + } + cfg := &appDevProjectConfig{ + Stack: doc.Stack, + Version: doc.Version, + BuildCommand: doc.Build.Command, + BuildOutput: strings.TrimSpace(doc.Build.Output), + AppID: strings.TrimSpace(doc.App.ID), + AppURL: strings.TrimSpace(doc.App.URL), + Source: miaodaJSONRelPath, + } + applyAppDevConfigDefaults(cfg) + return cfg, true, nil + } else if !os.IsNotExist(rerr) { + return nil, false, appsFileIOError(rerr, "read %s failed: %v", miaodaJSONRelPath, rerr) + } + + // Legacy fallback: .spark/meta.json (top-level app_id). + appID, isSpark, err := readMetaAppID(dir) + if err != nil { + return nil, false, err + } + if !isSpark { + return nil, false, nil + } + cfg = &appDevProjectConfig{ + AppID: strings.TrimSpace(appID), + Source: metaRelPath, + } + applyAppDevConfigDefaults(cfg) + return cfg, true, nil +} + +// applyAppDevConfigDefaults fills protocol defaults (§3): build.command → +// npm run build, build.output → dist. +func applyAppDevConfigDefaults(cfg *appDevProjectConfig) { + if len(cfg.BuildCommand) == 0 { + cfg.BuildCommand = append([]string(nil), appDevDefaultBuildCommand...) + } + if cfg.BuildOutput == "" { + cfg.BuildOutput = appDevDefaultBuildOutput + } +} + +// writeMiaodaAppSection replaces the app state section of /miaoda.json +// with {id, url} after a successful publish (§3: the app section is owned by +// the deploy chain and replaced wholesale; declaration fields are never +// touched). Empty url omits the key. Creates the file if missing. +func writeMiaodaAppSection(dir, appID, appURL string) error { + path := filepath.Join(dir, miaodaJSONRelPath) + doc := map[string]interface{}{} + if b, err := os.ReadFile(path); err == nil { //nolint:forbidigo // see readAppDevProjectConfig. + if jerr := json.Unmarshal(b, &doc); jerr != nil { + return appsFileIOError(jerr, "parse %s failed: %v", miaodaJSONRelPath, jerr) + } + } else if !os.IsNotExist(err) { + return appsFileIOError(err, "read %s failed: %v", miaodaJSONRelPath, err) + } + app := map[string]interface{}{"id": appID} + if appURL != "" { + app["url"] = appURL + } + doc["app"] = app + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return appsFileIOError(err, "marshal %s failed: %v", miaodaJSONRelPath, err) + } + if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // see above. + return appsFileIOError(err, "write %s failed: %v", miaodaJSONRelPath, err) + } + return nil +} + +// writeMiaodaScaffoldFields merge-writes the scaffold-owned fields into +// /miaoda.json after template rendering: version is always stamped with +// the rendered package version (authoritative), stack is only filled when the +// template seed did not declare one, and every other field the seed shipped +// (dev/build declarations, unknown fields) is preserved (§3 字段所有权). +func writeMiaodaScaffoldFields(dir, stack, version string) error { + path := filepath.Join(dir, miaodaJSONRelPath) + doc := map[string]interface{}{} + if b, err := os.ReadFile(path); err == nil { //nolint:forbidigo // see readAppDevProjectConfig. + if jerr := json.Unmarshal(b, &doc); jerr != nil { + return appsFileIOError(jerr, "parse %s failed: %v", miaodaJSONRelPath, jerr) + } + } else if !os.IsNotExist(err) { + return appsFileIOError(err, "read %s failed: %v", miaodaJSONRelPath, err) + } + if cur, _ := doc["stack"].(string); strings.TrimSpace(cur) == "" { + doc["stack"] = stack + } + doc["version"] = version + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return appsFileIOError(err, "marshal %s failed: %v", miaodaJSONRelPath, err) + } + if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // see above. + return appsFileIOError(err, "write %s failed: %v", miaodaJSONRelPath, err) + } + return nil +} diff --git a/shortcuts/apps/app_dev_template_fetch.go b/shortcuts/apps/app_dev_template_fetch.go index 24584e77e5..dd2f134c9e 100644 --- a/shortcuts/apps/app_dev_template_fetch.go +++ b/shortcuts/apps/app_dev_template_fetch.go @@ -297,28 +297,3 @@ func (c *countingReader) Read(p []byte) (int, error) { c.n += int64(n) return n, err } - -// writeAppDevSparkMeta merge-writes {stack, version} into -// /.spark/meta.json, creating the directory as needed. Field names align -// with miaoda-cli's SparkMeta so downstream tooling reads one format. -func writeAppDevSparkMeta(dir, stack, version string) error { - sparkDir := filepath.Join(dir, ".spark") - if err := os.MkdirAll(sparkDir, 0o755); err != nil { //nolint:forbidigo // see renderAppDevTemplate. - return appsFileIOError(err, "create .spark directory failed: %v", err) - } - metaPath := filepath.Join(dir, metaRelPath) - meta := map[string]interface{}{} - if b, err := os.ReadFile(metaPath); err == nil { //nolint:forbidigo // see above. - _ = json.Unmarshal(b, &meta) - } - meta["stack"] = stack - meta["version"] = version - out, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err) - } - if err := os.WriteFile(metaPath, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // see above. - return appsFileIOError(err, "write %s failed: %v", metaRelPath, err) - } - return nil -} diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_app_dev_init_template.go index a6d05c8ff0..f7cce13a2f 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -193,13 +193,13 @@ var AppsAppDevInitTemplate = common.Shortcut{ if err != nil { return err } - if err := writeAppDevSparkMeta(dir, template, version); err != nil { + if err := writeMiaodaScaffoldFields(dir, template, version); err != nil { return err } nextSteps := []string{ fmt.Sprintf("cd %s && npm install && npm run dev", dir), - "lark-cli apps +create --name , then write the returned app_id into .spark/meta.json", - "run lark-cli apps +app-dev-publish from the project root to build and deploy", + "lark-cli apps +create --name to create the Miaoda app", + "run lark-cli apps +app-dev-publish --app-id from the project root (saved into miaoda.json on success; later runs need no flag)", } data := map[string]interface{}{ "dir": dir, diff --git a/shortcuts/apps/apps_app_dev_init_template_test.go b/shortcuts/apps/apps_app_dev_init_template_test.go index 9cd6fe3539..89c55b44e5 100644 --- a/shortcuts/apps/apps_app_dev_init_template_test.go +++ b/shortcuts/apps/apps_app_dev_init_template_test.go @@ -271,24 +271,43 @@ func TestRenderAppDevTemplate_FileCountCap(t *testing.T) { } } -func TestWriteAppDevSparkMeta(t *testing.T) { +func TestWriteMiaodaScaffoldFields(t *testing.T) { dir := t.TempDir() - if err := writeAppDevSparkMeta(dir, "react-standard-webapp", "1.2.3"); err != nil { + // Fresh project: stack + version stamped. + if err := writeMiaodaScaffoldFields(dir, "react-standard-webapp", "1.2.3"); err != nil { t.Fatal(err) } - b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) + b, err := os.ReadFile(filepath.Join(dir, miaodaJSONRelPath)) if err != nil { t.Fatal(err) } - var meta map[string]interface{} - if err := json.Unmarshal(b, &meta); err != nil { + var doc map[string]interface{} + if err := json.Unmarshal(b, &doc); err != nil { t.Fatal(err) } - if meta["stack"] != "react-standard-webapp" || meta["version"] != "1.2.3" { - t.Errorf("meta = %v", meta) + if doc["stack"] != "react-standard-webapp" || doc["version"] != "1.2.3" { + t.Errorf("doc = %v", doc) } - if _, has := meta["archType"]; has { - t.Error("meta.json must not carry archType (not part of the contract)") + // Seed-shipped declarations are preserved; seed stack wins; version is + // re-stamped with the rendered package version. + seed := `{"stack":"seed-stack","version":"0.0.1","build":{"command":["make","dist"],"output":"out"},"dev":{"port":5173}}` + if err := os.WriteFile(filepath.Join(dir, miaodaJSONRelPath), []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + if err := writeMiaodaScaffoldFields(dir, "react-standard-webapp", "2.0.0"); err != nil { + t.Fatal(err) + } + b, _ = os.ReadFile(filepath.Join(dir, miaodaJSONRelPath)) + doc = map[string]interface{}{} + _ = json.Unmarshal(b, &doc) + if doc["stack"] != "seed-stack" { + t.Errorf("seed stack must not be overwritten, got %v", doc["stack"]) + } + if doc["version"] != "2.0.0" { + t.Errorf("version must be re-stamped, got %v", doc["version"]) + } + if doc["build"] == nil || doc["dev"] == nil { + t.Errorf("seed declarations must be preserved: %v", doc) } } @@ -553,15 +572,15 @@ func TestAppDevInitTemplateExecute_RendersFromRegistry(t *testing.T) { if err != nil || !strings.Contains(string(b), dir) { t.Errorf("index.html placeholder = %q err=%v (projectName is dir basename)", b, err) } - // meta.json written by lark-cli. - mb, err := os.ReadFile(filepath.Join(dir, metaRelPath)) + // miaoda.json written by lark-cli (protocol §3). + mb, err := os.ReadFile(filepath.Join(dir, miaodaJSONRelPath)) if err != nil { t.Fatal(err) } - var meta map[string]interface{} - _ = json.Unmarshal(mb, &meta) - if meta["stack"] != "react-standard-webapp" || meta["version"] != "1.2.3" { - t.Errorf("meta = %v", meta) + var doc map[string]interface{} + _ = json.Unmarshal(mb, &doc) + if doc["stack"] != "react-standard-webapp" || doc["version"] != "1.2.3" { + t.Errorf("miaoda.json = %v", doc) } steps, _ := data["next_steps"].([]interface{}) if len(steps) != 3 { diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 9bf43ce338..727c4f375f 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -167,49 +167,47 @@ func appDevSensitiveCandidatesError(hits []string) error { WithHint("remove these files from the build output, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)") } -// resolveAppDevPublishAppID resolves the publish target from --app-id and -// .spark/meta.json: -// - flag only -> use it (backfilled into meta.json after a -// successful publish, so retries and later runs need no flag) -// - meta only -> use it (the zero-flag iteration path) +// resolveAppDevPublishTarget loads the project declaration (miaoda.json +// first, legacy .spark/meta.json fallback) and resolves the publish target +// from --app-id and the recorded app id: +// - flag only -> use it (written back after a successful publish) +// - recorded only -> use it (the zero-flag iteration path) // - both, equal -> fine // - both, different -> refuse: silently overwriting the recorded // target could ship the build to the wrong app // - neither -> guide the user to +create first -// -// fromFlag reports whether the value came from --app-id (drives backfill). -func resolveAppDevPublishAppID(rctx *common.RuntimeContext) (appID string, fromFlag bool, err error) { +func resolveAppDevPublishTarget(rctx *common.RuntimeContext) (cfg *appDevProjectConfig, appID string, fromFlag bool, err error) { flagID := strings.TrimSpace(rctx.Str("app-id")) - metaID, isSpark, err := readMetaAppID(".") + cfg, found, err := readAppDevProjectConfig(".") if err != nil { - return "", false, err + return nil, "", false, err } - if !isSpark { - return "", false, appsFailedPreconditionError( - "current directory is not a Miaoda app project (.spark/meta.json not found)"). + if !found { + return nil, "", false, appsFailedPreconditionError( + "current directory is not a Miaoda app project (miaoda.json not found)"). WithHint("run this command from the project root; scaffold a project with +app-dev-init-template first") } - metaID = strings.TrimSpace(metaID) + recorded := cfg.AppID switch { - case flagID == "" && metaID == "": - return "", false, appsFailedPreconditionError("no publish target: .spark/meta.json has no app_id and --app-id was not given"). - WithHint("create the app first with `lark-cli apps +create --name `, then publish with `lark-cli apps +app-dev-publish --app-id ` (the id is saved into .spark/meta.json on success)") - case flagID != "" && metaID != "" && flagID != metaID: - return "", false, appsFailedPreconditionParamError("--app-id", - ".spark/meta.json already records app_id %s but --app-id is %s; refusing to silently switch the publish target", metaID, flagID). - WithHint("drop --app-id to publish to the recorded app, or update app_id in .spark/meta.json first if you really mean to switch") + case flagID == "" && recorded == "": + return nil, "", false, appsFailedPreconditionError("no publish target: %s has no app id and --app-id was not given", cfg.Source). + WithHint("create the app first with `lark-cli apps +create --name `, then publish with `lark-cli apps +app-dev-publish --app-id ` (the id is saved into miaoda.json on success)") + case flagID != "" && recorded != "" && flagID != recorded: + return nil, "", false, appsFailedPreconditionParamError("--app-id", + "%s already records app id %s but --app-id is %s; refusing to silently switch the publish target", cfg.Source, recorded, flagID). + WithHint("drop --app-id to publish to the recorded app, or update the recorded app id first if you really mean to switch") case flagID != "": if err := validateRealAppID(flagID); err != nil { - return "", false, err + return nil, "", false, err } - return flagID, metaID == "", nil + return cfg, flagID, recorded == "", nil default: - if !strings.HasPrefix(metaID, "app_") { - return "", false, appsFailedPreconditionError( - `.spark/meta.json app_id %q is invalid (must start with "app_")`, metaID). - WithHint("fix app_id in .spark/meta.json: find the right id with `lark-cli apps +list`, or create the app with `lark-cli apps +create --name `") + if !strings.HasPrefix(recorded, "app_") { + return nil, "", false, appsFailedPreconditionError( + `%s app id %q is invalid (must start with "app_")`, cfg.Source, recorded). + WithHint("fix the recorded app id: find the right one with `lark-cli apps +list`, or create the app with `lark-cli apps +create --name `") } - return metaID, false, nil + return cfg, recorded, false, nil } } @@ -261,12 +259,13 @@ var AppsAppDevPublish = common.Shortcut{ AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ - {Name: "app-id", Desc: "publish target app ID (app_ prefix); optional when .spark/meta.json already records one — on a successful publish it is saved into .spark/meta.json, and a value conflicting with the recorded one is rejected"}, - {Name: "skip-build", Type: "bool", Desc: "skip npm run build and publish the existing ./dist as-is"}, + {Name: "app-id", Desc: "publish target app ID (app_ prefix); optional when miaoda.json already records one — on a successful publish it is saved back into miaoda.json, and a value conflicting with the recorded one is rejected"}, + {Name: "skip-build", Type: "bool", Desc: "skip the build.command declared in miaoda.json (default npm run build) and publish the existing build.output directory as-is"}, {Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / etc. in the publish payload)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - if _, _, err := resolveAppDevPublishAppID(rctx); err != nil { + cfg, _, _, err := resolveAppDevPublishTarget(rctx) + if err != nil { return err } // Sensitive-file scan lives in Validate so that --dry-run exits @@ -288,55 +287,60 @@ var AppsAppDevPublish = common.Shortcut{ } } if rctx.Bool("skip-build") { - if _, err := rctx.FileIO().Stat(appDevDistDir); err != nil { - return appsFailedPreconditionError("--skip-build is set but ./dist does not exist"). - WithHint("run npm run build first, or drop --skip-build to let the command build") + if _, err := rctx.FileIO().Stat(cfg.BuildOutput); err != nil { + return appsFailedPreconditionError("--skip-build is set but the build output directory %s does not exist", cfg.BuildOutput). + WithHint("run the build first, or drop --skip-build to let the command build") } - } else if _, err := appDevLookPath("npm"); err != nil { - return appsFailedPreconditionError("npm executable not found on PATH"). - WithHint("install Node.js (which provides npm), or build manually and retry with --skip-build") + } else if _, err := appDevLookPath(cfg.BuildCommand[0]); err != nil { + return appsFailedPreconditionError("build command executable %q not found on PATH", cfg.BuildCommand[0]). + WithHint("install it (default build.command is npm run build, provided by Node.js), or build manually and retry with --skip-build") } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { dry := common.NewDryRunAPI(). Desc("Read .spark/meta.json app_id -> GET pre_release (upload_url/tos_path + MIAODA_* build env) -> npm run build -> validate dist layout -> zip -> PUT to TOS -> POST releases; returns online_url (sync) or release_id (async)") - appID, fromFlag, err := resolveAppDevPublishAppID(rctx) + cfg, appID, fromFlag, err := resolveAppDevPublishTarget(rctx) + if cfg == nil { + cfg = &appDevProjectConfig{Source: miaodaJSONRelPath} + applyAppDevConfigDefaults(cfg) + } switch { case err != nil: dry.Set("meta_error", err.Error()) default: dry.Set("app_id", appID) if fromFlag { - dry.Set("app_id_source", "--app-id flag (will be saved into .spark/meta.json on success)") + dry.Set("app_id_source", "--app-id flag (will be saved into miaoda.json on success)") } else { - dry.Set("app_id_source", ".spark/meta.json") + dry.Set("app_id_source", cfg.Source) } dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))). PUT(" (https only, from pre_release kvs)"). POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))). Body(map[string]string{"tos_path": ""}) } - dry.Set("build_command", "npm run build (env allowlist: MIAODA_* keys from pre_release; skipped with --skip-build)") - if candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), appDevDistDir); err != nil { + dry.Set("build_command", strings.Join(cfg.BuildCommand, " ")+" (from miaoda.json build.command, default npm run build; env allowlist: MIAODA_* keys from pre_release; skipped with --skip-build)") + dry.Set("build_output", cfg.BuildOutput) + if candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), cfg.BuildOutput); err != nil { dry.Set("dist_state", "missing or unreadable: "+err.Error()) } else { dry.Set("dist_file_count", len(candidates)) - if _, verr := validateAppDevDist(rctx.FileIO(), appDevDistDir, rctx.Bool("allow-sensitive")); verr != nil { + if _, verr := validateAppDevDist(rctx.FileIO(), cfg.BuildOutput, rctx.Bool("allow-sensitive")); verr != nil { dry.Set("dist_validation_error", verr.Error()) } } return dry }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - appID, fromFlag, err := resolveAppDevPublishAppID(rctx) + cfg, appID, fromFlag, err := resolveAppDevPublishTarget(rctx) if err != nil { return err } // The server-side owner check is the only authorization line — echo // the target loudly so a wrong app_id is visible before anything // ships, naming where the id came from. - source := metaRelPath + source := cfg.Source if fromFlag { source = "--app-id" } @@ -364,15 +368,16 @@ var AppsAppDevPublish = common.Shortcut{ if len(keys) > 0 { fmt.Fprintf(rctx.IO().ErrOut, "injecting build env: %s\n", strings.Join(keys, ", ")) } - fmt.Fprintln(rctx.IO().ErrOut, "running npm run build...") - if _, stderr, err := appDevRunner.RunEnv(ctx, "", env, "npm", "run", "build"); err != nil { - return appsExternalToolError(err, "npm run build failed: %s", gitErr(stderr, err)). - WithHint("fix the build errors and retry; or build manually and retry with --skip-build") + buildCmd := cfg.BuildCommand + fmt.Fprintf(rctx.IO().ErrOut, "running build: %s\n", strings.Join(buildCmd, " ")) + if _, stderr, err := appDevRunner.RunEnv(ctx, "", env, buildCmd[0], buildCmd[1:]...); err != nil { + return appsExternalToolError(err, "build command %q failed: %s", strings.Join(buildCmd, " "), gitErr(stderr, err)). + WithHint("fix the build errors and retry; or build manually and retry with --skip-build (build.command is declared in miaoda.json)") } built = true } - candidates, err := validateAppDevDist(rctx.FileIO(), appDevDistDir, rctx.Bool("allow-sensitive")) + candidates, err := validateAppDevDist(rctx.FileIO(), cfg.BuildOutput, rctx.Bool("allow-sensitive")) if err != nil { return err } @@ -406,15 +411,6 @@ var AppsAppDevPublish = common.Shortcut{ return withAppsHint(err, "verify the app supports artifact-hosting publish; list your apps with `lark-cli apps +list`") } - // The release was accepted — persist a flag-provided app_id so later - // runs need no flag ("deploy-time fill-in" per the design doc). Only - // fills a missing app_id; never overwrites (mismatch was rejected in - // Validate). Best-effort: a write failure must not fail the publish. - if fromFlag { - if err := ensureMetaAppID(".", appID); err != nil { - fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to save app_id into %s: %v\n", metaRelPath, err) - } - } releaseID := common.GetString(releaseData, "release_id") status := common.GetString(releaseData, "status") onlineURL := common.GetString(releaseData, "online_url") @@ -429,13 +425,31 @@ var AppsAppDevPublish = common.Shortcut{ pollHint := "" if onlineURL != "" { data["online_url"] = onlineURL - if err := ensureMetaOnlineURL(".", onlineURL); err != nil { - fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to backfill online_url into %s: %v\n", metaRelPath, err) - } } else { pollHint = fmt.Sprintf("lark-cli apps +release-get --app-id %s --release-id %s", appID, releaseID) data["poll_hint"] = pollHint } + // The release was accepted — write the app state back per protocol + // (§3): miaoda.json gets the app section replaced wholesale; the + // legacy .spark/meta.json fallback keeps its old field names and is + // only ever filled, never rewritten. Best-effort: a write failure + // must not fail the publish. + if cfg.Source == miaodaJSONRelPath { + if err := writeMiaodaAppSection(".", appID, onlineURL); err != nil { + fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to write app state into %s: %v\n", miaodaJSONRelPath, err) + } + } else { + if fromFlag { + if err := ensureMetaAppID(".", appID); err != nil { + fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to save app_id into %s: %v\n", metaRelPath, err) + } + } + if onlineURL != "" { + if err := ensureMetaOnlineURL(".", onlineURL); err != nil { + fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to backfill online_url into %s: %v\n", metaRelPath, err) + } + } + } rctx.OutFormat(data, nil, func(w io.Writer) { fmt.Fprintf(w, "app_id: %s\nrelease_id: %s\nstatus: %s\n", appID, releaseID, status) if onlineURL != "" { diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index 0173a96194..03d8f07695 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -238,8 +238,29 @@ func withFakeEnvRunner(t *testing.T, f *fakeEnvRunner) { t.Cleanup(func() { appDevRunner = orig }) } +// chdirMiaodaProjectRoot creates a temp project root with miaoda.json and +// chdirs into it (the protocol-first path). +func chdirMiaodaProjectRoot(t *testing.T, miaodaJSON string) string { + t.Helper() + root := t.TempDir() + if miaodaJSON != "" { + if err := os.WriteFile(filepath.Join(root, miaodaJSONRelPath), []byte(miaodaJSON), 0o644); err != nil { + t.Fatal(err) + } + } + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(old) }) + return root +} + // chdirProjectRoot creates a temp project root with .spark/meta.json and -// chdirs into it for the test (the shortcut reads meta.json from cwd). +// chdirs into it for the test (legacy fallback path). func chdirProjectRoot(t *testing.T, metaJSON string) string { t.Helper() root := t.TempDir() @@ -311,6 +332,9 @@ func TestAppDevPublishValidate_NoMeta(t *testing.T) { if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "not a Miaoda app project") { t.Errorf("got %v", p) } + if !strings.Contains(p.Message, "miaoda.json") { + t.Errorf("message should name miaoda.json, got %q", p.Message) + } if !strings.Contains(p.Hint, "+app-dev-init-template") { t.Errorf("hint = %q", p.Hint) } @@ -383,8 +407,8 @@ func TestAppDevPublishValidate_BadAppID(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) - if !strings.Contains(p.Message, ".spark/meta.json app_id") { - t.Errorf("message should point at meta.json, got %q", p.Message) + if !strings.Contains(p.Message, ".spark/meta.json app id") { + t.Errorf("message should point at the config source, got %q", p.Message) } // This command has no --app-id flag; the error must not mention one. if strings.Contains(p.Message, "--app-id") || strings.Contains(p.Hint, "--app-id") { @@ -424,7 +448,7 @@ func TestAppDevPublishValidate_SkipBuildNoDist(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) - if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "./dist does not exist") { + if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "build output directory dist does not exist") { t.Errorf("got %v", p) } } @@ -527,7 +551,7 @@ func TestAppDevPublishExecute_BuildFails(t *testing.T) { stubPreRelease(reg, "app_x", srv.URL, nil) err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryInternal) - if !strings.Contains(p.Message, "npm run build failed") || !strings.Contains(p.Message, "TS2304") { + if !strings.Contains(p.Message, `build command "npm run build" failed`) || !strings.Contains(p.Message, "TS2304") { t.Errorf("message = %q", p.Message) } if !strings.Contains(p.Hint, "--skip-build") { @@ -602,6 +626,83 @@ func TestAppDevPublishDryRun(t *testing.T) { } } +func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { + // miaoda.json declares a custom build command and output dir; the app + // section is replaced wholesale on success. + root := chdirMiaodaProjectRoot(t, `{ + "stack": "custom-webapp", + "build": { "command": ["make", "site"], "output": "public" }, + "app": { "id": "app_x" } +}`) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + f := &fakeEnvRunner{sideEffect: func() { + writeDistFiles(t, filepath.Join(root, "public"), []string{"output/index.html", "output/routes.json"}) + routes := `{"version":1,"type":"custom-webapp","fallback":"index.html"}` + os.WriteFile(filepath.Join(root, "public", "output", "routes.json"), []byte(routes), 0o644) + }} + withFakeEnvRunner(t, f) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + stubReleases(reg, "app_x", map[string]interface{}{ + "release_id": "rel_20", "status": "finished", + "online_url": "https://x/app/app_x", + }) + if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + // Declared build command executed (not npm run build). + if f.name != "make" || len(f.args) != 1 || f.args[0] != "site" { + t.Errorf("build call = %v %v, want make site", f.name, f.args) + } + // App section replaced wholesale with id+url; declarations preserved. + b, _ := os.ReadFile(filepath.Join(root, miaodaJSONRelPath)) + var doc map[string]interface{} + _ = json.Unmarshal(b, &doc) + app, _ := doc["app"].(map[string]interface{}) + if app == nil || app["id"] != "app_x" || app["url"] != "https://x/app/app_x" { + t.Errorf("app section = %v", doc["app"]) + } + if doc["stack"] != "custom-webapp" || doc["build"] == nil { + t.Errorf("declaration fields must be preserved: %v", doc) + } +} + +func TestAppDevPublishExecute_MiaodaFlagBackfill(t *testing.T) { + // No recorded app id in miaoda.json: --app-id publishes and the app + // section is written on success (async: no url yet). + root := chdirMiaodaProjectRoot(t, `{"stack":"react-standard-webapp"}`) + writeDistFiles(t, filepath.Join(root, appDevDefaultBuildOutput), []string{"output/index.html", "output/routes.json"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_new1", srv.URL, nil) + stubReleases(reg, "app_new1", map[string]interface{}{"release_id": "rel_21", "status": "pending"}) + if err := runAppsShortcut(t, AppsAppDevPublish, + []string{"+app-dev-publish", "--app-id", "app_new1", "--skip-build", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + b, _ := os.ReadFile(filepath.Join(root, miaodaJSONRelPath)) + var doc map[string]interface{} + _ = json.Unmarshal(b, &doc) + app, _ := doc["app"].(map[string]interface{}) + if app == nil || app["id"] != "app_new1" { + t.Errorf("app section = %v", doc["app"]) + } + if _, has := app["url"]; has { + t.Error("async publish must not write app.url") + } +} + +func TestAppDevPublishValidate_MiaodaMismatch(t *testing.T) { + chdirMiaodaProjectRoot(t, `{"app": {"id": "app_recorded"}}`) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAppDevPublish, + []string{"+app-dev-publish", "--app-id", "app_other", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if !strings.Contains(p.Message, "miaoda.json") || !strings.Contains(p.Message, "app_recorded") { + t.Errorf("message = %q", p.Message) + } +} + func TestAppsAppDevPublish_Declaration(t *testing.T) { if AppsAppDevPublish.Command != "+app-dev-publish" { t.Errorf("Command = %q", AppsAppDevPublish.Command) From 92e25668c55a269b6304f9c094401fd306889b37 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 15:46:44 +0800 Subject: [PATCH 15/51] feat(apps): enforce protocol output layout and routes.json schema --- shortcuts/apps/apps_app_dev_publish.go | 63 ++++++++++++++++----- shortcuts/apps/apps_app_dev_publish_test.go | 34 ++++++++++- 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 727c4f375f..1d1fe3c1bf 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -98,7 +98,7 @@ func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) } return nil, err } - var hasIndex, hasRoutes, hasOutput bool + var hasHTML, hasRoutes, hasOutput bool var extras []string seenExtras := map[string]bool{} for _, c := range candidates { @@ -109,13 +109,15 @@ func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) switch top { case "output": hasOutput = true - switch c.RelPath { - case "output/index.html": - hasIndex = true - case "output/routes.json": + if strings.HasSuffix(c.RelPath, ".html") { + hasHTML = true + } + if c.RelPath == "output/routes.json" { hasRoutes = true } - case "output_resource": + case "output_resource", "output_capabilities": + // output_resource ships to CDN; output_capabilities is the + // platform-capability placeholder — both ride along in the zip. default: if !seenExtras[top] { seenExtras[top] = true @@ -126,22 +128,25 @@ func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) if len(extras) > 0 { sort.Strings(extras) return nil, appsValidationError( - "dist contains %d top-level entr(ies) outside the artifact-hosting layout: %s", + "the build output contains %d top-level entr(ies) outside the artifact-hosting layout: %s", len(extras), truncatedJoin(extras, maxSensitiveListInError)). - WithHint("only output/ and output_resource/ are uploaded; adjust the build output") + WithHint("only output/, output_resource/ and output_capabilities/ are uploaded; adjust the build output") } if !hasOutput { return nil, appsFailedPreconditionError( - "dist is missing the output/ directory required by the artifact-hosting layout"). - WithHint("build first (npm run build); expected layout: dist/output/{index.html,routes.json} + dist/output_resource/") + "the build output is missing the output/ directory required by the artifact-hosting layout"). + WithHint("run the build first; expected layout: /output/{*.html,routes.json} + output_resource/") } - if !hasIndex { - return nil, appsFailedPreconditionError("dist/output is missing index.html"). - WithHint("output/index.html is the app entrypoint; check the template's build config") + if !hasHTML { + return nil, appsFailedPreconditionError("output/ has no .html file; the protocol requires at least one (an SPA entry must be named index.html)"). + WithHint("check the build config: HTML entries belong in output/, hashed assets in output_resource/") } if !hasRoutes { - return nil, appsFailedPreconditionError("dist/output is missing routes.json"). - WithHint("routes.json is required for content review routing; miaoda-cli templates generate it during npm run build") + return nil, appsFailedPreconditionError("output/routes.json is missing"). + WithHint("routes.json is required for content review routing; official templates generate it during the build") + } + if err := validateAppDevRoutesJSON(distPath); err != nil { + return nil, err } if !allowSensitive { var hits []string @@ -157,6 +162,34 @@ func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) return candidates, nil } +// appDevRoutesJSON is the routes.json v1 schema (fallback-only object). All +// three fields are MUST per the artifact-hosting protocol; unknown fields are +// ignored for forward compatibility. +type appDevRoutesJSON struct { + Version int `json:"version"` + Type string `json:"type"` + Fallback string `json:"fallback"` +} + +// validateAppDevRoutesJSON light-checks output/routes.json so schema problems +// fail at publish time instead of bouncing off content review later. +func validateAppDevRoutesJSON(distPath string) error { + b, err := os.ReadFile(filepath.Join(distPath, "output", "routes.json")) //nolint:forbidigo // path is under the walked build output. + if err != nil { + return appsFileIOError(err, "read output/routes.json failed: %v", err) + } + var r appDevRoutesJSON + if err := json.Unmarshal(b, &r); err != nil { + return appsFailedPreconditionError("output/routes.json is not valid JSON: %v", err). + WithHint(`expected schema: {"version":1,"type":"","fallback":"index.html"}`) + } + if r.Version == 0 || strings.TrimSpace(r.Type) == "" || strings.TrimSpace(r.Fallback) == "" { + return appsFailedPreconditionError("output/routes.json is missing required fields (version/type/fallback are all required)"). + WithHint(`expected schema: {"version":1,"type":"","fallback":"index.html"}`) + } + return nil +} + // appDevSensitiveCandidatesError mirrors sensitiveCandidatesError with // publish-specific wording: this command has no --path flag and the payload // is always ./dist, so the html-publish message would misdirect the user. diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index 03d8f07695..8f30d8a543 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -77,7 +77,9 @@ func TestEnsureMetaOnlineURL(t *testing.T) { // --- dist layout validation --- -// writeDistFiles creates files (relative to base) with parent dirs. +// writeDistFiles creates files (relative to base) with parent dirs. A file +// named routes.json gets valid v1 schema content so protocol validation +// passes by default; tests that need a broken one overwrite it afterwards. func writeDistFiles(t *testing.T, base string, files []string) { t.Helper() for _, f := range files { @@ -85,7 +87,11 @@ func writeDistFiles(t *testing.T, base string, files []string) { if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + body := "x" + if strings.HasSuffix(f, "routes.json") { + body = `{"version":1,"type":"test-stack","fallback":"index.html"}` + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } } @@ -99,8 +105,10 @@ func TestValidateAppDevDist(t *testing.T) { }{ {"ok full", []string{"output/index.html", "output/routes.json", "output_resource/index.js"}, ""}, {"ok no resource", []string{"output/index.html", "output/routes.json"}, ""}, + {"ok non-index html", []string{"output/page.html", "output/routes.json"}, ""}, + {"ok capabilities dir", []string{"output/index.html", "output/routes.json", "output_capabilities/cap.json"}, ""}, {"no output dir", []string{"stray.txt"}, "top-level entr"}, - {"no index", []string{"output/routes.json"}, "index.html"}, + {"no html", []string{"output/routes.json"}, "no .html file"}, {"no routes", []string{"output/index.html"}, "routes.json"}, {"extra top-level dir", []string{"output/index.html", "output/routes.json", "extra/x.js"}, "outside the artifact-hosting layout"}, {"extra top-level file", []string{"output/index.html", "output/routes.json", "notes.md"}, "outside the artifact-hosting layout"}, @@ -123,6 +131,26 @@ func TestValidateAppDevDist(t *testing.T) { } } +func TestValidateAppDevDist_RoutesSchema(t *testing.T) { + dist := filepath.Join(t.TempDir(), "dist") + writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json"}) + // Broken JSON. + os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte("not-json"), 0o644) + if _, err := validateAppDevDist(permissiveFIO{}, dist, false); err == nil || !strings.Contains(err.Error(), "not valid JSON") { + t.Errorf("broken routes.json must be rejected, got %v", err) + } + // Missing required fields. + os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte(`{"version":1}`), 0o644) + if _, err := validateAppDevDist(permissiveFIO{}, dist, false); err == nil || !strings.Contains(err.Error(), "missing required fields") { + t.Errorf("incomplete routes.json must be rejected, got %v", err) + } + // Unknown fields ignored (forward compatible). + os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte(`{"version":1,"type":"t","fallback":"index.html","future":true}`), 0o644) + if _, err := validateAppDevDist(permissiveFIO{}, dist, false); err != nil { + t.Errorf("unknown fields must be ignored: %v", err) + } +} + func TestValidateAppDevDist_Missing(t *testing.T) { _, err := validateAppDevDist(permissiveFIO{}, filepath.Join(t.TempDir(), "dist"), false) p := requireAppsProblem(t, err, errs.CategoryValidation) From 95c0035e2db7beb32d8c10fce94ce56d61cda3d1 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 15:47:56 +0800 Subject: [PATCH 16/51] docs(apps): align references with miaoda.json protocol --- .../lark-apps-app-dev-init-template.md | 6 +++--- .../references/lark-apps-app-dev-publish.md | 20 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-template.md b/skills/lark-apps/references/lark-apps-app-dev-init-template.md index 2fd288c601..57a7bfbf99 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-init-template.md +++ b/skills/lark-apps/references/lark-apps-app-dev-init-template.md @@ -24,9 +24,9 @@ lark-cli apps +app-dev-init-template --type full_stack --dry-run 返回 `data.dir`(项目目录)、`data.template`、`data.stack` 和 `data.next_steps`(后续步骤清单)。按 next_steps 引导用户: -1. `cd && npm install && npm run dev` 本地开发预览; -2. 需要发布时先 `lark-cli apps +create --name ` 创建妙搭应用,把返回的 `app_id` 写入项目根的 `.spark/meta.json`; -3. 在项目根运行 `lark-cli apps +app-dev-publish` 构建并发布(见 [lark-apps-app-dev-publish.md](lark-apps-app-dev-publish.md))。 +1. `cd && npm install && npm run dev` 本地开发预览(dev 命令声明见项目根 `miaoda.json`); +2. 需要发布时先 `lark-cli apps +create --name ` 创建妙搭应用; +3. 在项目根运行 `lark-cli apps +app-dev-publish --app-id <返回的 app_id>` 构建并发布(成功后 app id 写入 miaoda.json,后续免传;见 [lark-apps-app-dev-publish.md](lark-apps-app-dev-publish.md))。 ## 常见失败 diff --git a/skills/lark-apps/references/lark-apps-app-dev-publish.md b/skills/lark-apps/references/lark-apps-app-dev-publish.md index c4b78f483f..f60032218a 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-publish.md +++ b/skills/lark-apps/references/lark-apps-app-dev-publish.md @@ -8,11 +8,11 @@ ## 命令骨架 -- **必须在项目根目录执行**(项目根须有 `.spark/meta.json`);产物目录固定为 `./dist`,无 `--path` 参数。 -- `--app-id` 可选:首次发布传它指定目标(成功后自动写入 `.spark/meta.json`,后续免传);meta.json 已有 app_id 时可省略;**两者都有且不一致会被拒绝**(防误发错目标),确要切换先更新 meta.json。 +- **必须在项目根目录执行**(项目根须有 `miaoda.json`;旧项目回退读 `.spark/meta.json`)。产物目录取 miaoda.json 的 `build.output`(缺省 `dist`),无 `--path` 参数。 +- `--app-id` 可选:首次发布传它指定目标(成功后自动写入 `miaoda.json` 的 app 段,后续免传);已记录 app id 时可省略;**两者都有且不一致会被拒绝**(防误发错目标),确要切换先更新 miaoda.json。 - 可选:`--skip-build`(跳过 `npm run build`,直接发布已有 `./dist`)、`--allow-sensitive`(跳过凭据文件扫描)。 -- 内部流程:读 meta.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → `npm run build`(自动注入这些变量)→ 校验 dist 产物协议 → zip 上传 → 触发发布。 -- 产物协议:`dist/output/` 必须含 `index.html` 与 `routes.json`;`dist/output_resource/` 可选;dist 顶层不允许其他条目。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 +- 内部流程:读 miaoda.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → 执行 `build.command`(缺省 `npm run build`,argv 直接执行不走 shell,自动注入变量)→ 校验产物协议 → zip 上传 → 触发发布。 +- 产物协议(详见《妙搭产物托管协议规范》):`output/` 必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(`{"version":1,"type":"","fallback":"index.html"}`);`output_resource/`、`output_capabilities/` 可选;顶层不允许其他条目。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 ## 示例 @@ -25,14 +25,14 @@ lark-cli apps +app-dev-publish --dry-run ## 输出契约 -- 同步完成:`data.online_url` 直接可访问,同时回填进 `.spark/meta.json`。 +- 同步完成:`data.online_url` 直接可访问,同时随 app 段回写进 `miaoda.json`。 - 异步发布:返回 `data.release_id` 和 `data.poll_hint`;用 `+release-get --app-id --release-id ` 轮询到 `finished` 后读取 `online_url`。 - 业务失败通常带 `error.hint`,优先转述 hint;网络/服务端 5xx 失败带 `retryable`,可稍后重试。 ## 前置引导 -- meta.json 缺 `app_id` 时:先 `lark-cli apps +create --name ` 创建应用,然后 `lark-cli apps +app-dev-publish --app-id <返回的 app_id>` 发布(成功后 app_id 自动写入 meta.json,无需手工编辑文件);应用名可从项目主题生成,不要让用户手动提供 app_id。 -- **`app_id` 不是本会话写入的**(来自历史文件或他人仓库)时,发布前先把目标 `app_id` 告知用户并确认——发布会覆盖该应用的线上内容。 +- 未记录 app id 时:先 `lark-cli apps +create --name ` 创建应用,然后 `lark-cli apps +app-dev-publish --app-id <返回的 app_id>` 发布(成功后自动写入 miaoda.json,无需手工编辑文件);应用名可从项目主题生成,不要让用户手动提供 app_id。 +- **记录的 app id 不是本会话写入的**(来自历史文件或他人仓库)时,发布前先把目标 app id 告知用户并确认——发布会覆盖该应用的线上内容。 ## 安全规则 @@ -41,7 +41,7 @@ lark-cli apps +app-dev-publish --dry-run ## 常见失败 -- `current directory is not a Miaoda app project`:不在项目根执行;`cd` 到含 `.spark/meta.json` 的目录。 -- `dist/output is missing routes.json`:模板构建脚本负责生成;让用户检查是否改动了构建配置,不要手工伪造 routes.json。 -- `npm run build failed`:转述 stderr 摘要让用户修构建错误;用户已手动构建时可用 `--skip-build`。 +- `current directory is not a Miaoda app project`:不在项目根执行;`cd` 到含 `miaoda.json` 的目录。 +- `output/routes.json is missing` / schema 校验失败:模板构建脚本负责生成合法 routes.json;让用户检查构建配置,不要手工伪造。 +- `build command ... failed`:转述 stderr 摘要让用户修构建错误(构建命令来自 miaoda.json `build.command`);用户已手动构建时可用 `--skip-build`。 - `--skip-build is set but ./dist does not exist`:先构建或去掉 `--skip-build`。 From ba9239da6f58bc2315d80c561bd5a30318e3898d Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 16:35:14 +0800 Subject: [PATCH 17/51] feat(apps): scaffold in place by default for init-template --- shortcuts/apps/apps_app_dev_init_template.go | 36 ++++++++++++++----- .../apps/apps_app_dev_init_template_test.go | 26 +++++++++++--- .../lark-apps-app-dev-init-template.md | 2 +- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_app_dev_init_template.go index f7cce13a2f..13e4056178 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -69,15 +69,26 @@ func resolveAppDevTemplate(rctx *common.RuntimeContext) (string, error) { } // resolveAppDevDir returns the scaffold target directory: --dir when set, -// otherwise ./. -func resolveAppDevDir(dir, template string) string { +// otherwise the current directory (in-place init, matching miaoda-cli's +// app init which scaffolds into process.cwd()). +func resolveAppDevDir(dir string) string { d := strings.TrimSpace(dir) if d == "" { - return filepath.Join(".", template) + return "." } return d } +// appDevProjectName derives the {{projectName}} placeholder value from the +// target directory: its base name, resolved to the real directory name when +// scaffolding in place (base of "." is "."). +func appDevProjectName(dir string) string { + if abs, err := filepath.Abs(dir); err == nil { + return filepath.Base(abs) + } + return filepath.Base(dir) +} + // validateAppDevDir rejects absolute paths and .. traversal in --dir, keeping // scaffolding inside the working directory. func validateAppDevDir(dir string) error { @@ -109,6 +120,11 @@ func ensureAppDevDirUsable(dir string) error { return appsFileIOError(err, "read target directory %s failed: %v", dir, err) } if len(entries) > 0 { + if dir == "." { + return appsFailedPreconditionParamError("--dir", + "the current directory is not empty; scaffolding in place needs an empty directory"). + WithHint("run from an empty project directory, or pass --dir to scaffold into a subdirectory") + } return appsFailedPreconditionParamError("--dir", "target directory %s already exists and is not empty", dir). WithHint("choose an empty or new directory with --dir, or remove the existing contents first") @@ -138,7 +154,7 @@ var AppsAppDevInitTemplate = common.Shortcut{ Flags: []common.Flag{ {Name: "type", Desc: "app type; maps to a template package (frontend=react-standard-webapp, full_stack=react-express-standard-fullstack); ignored when --template is set", Enum: []string{"frontend", "full_stack"}}, {Name: "template", Desc: "template short name to use directly (resolves to @lark-apaas/coding-template-); takes precedence over --type"}, - {Name: "dir", Desc: "target directory, relative path (default ./); must be new or empty"}, + {Name: "dir", Desc: "target directory, relative path (default: current directory, scaffolding in place); must be empty or new"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := resolveAppDevTemplate(rctx); err != nil { @@ -148,7 +164,7 @@ var AppsAppDevInitTemplate = common.Shortcut{ }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { template, _ := resolveAppDevTemplate(rctx) // Validate already rejected invalid input - dir := resolveAppDevDir(rctx.Str("dir"), template) + dir := resolveAppDevDir(rctx.Str("dir")) pkg := appDevTemplatePackageName(template) dry := common.NewDryRunAPI(). Desc("Scaffold a local web app project by downloading an npm template package (read-only registry fetch, no Lark API)") @@ -174,7 +190,7 @@ var AppsAppDevInitTemplate = common.Shortcut{ if err != nil { return err } - dir := resolveAppDevDir(rctx.Str("dir"), template) + dir := resolveAppDevDir(rctx.Str("dir")) if err := ensureAppDevDirUsable(dir); err != nil { return err } @@ -189,15 +205,19 @@ var AppsAppDevInitTemplate = common.Shortcut{ if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:forbidigo // see ensureAppDevDirUsable return appsFileIOError(err, "create target directory %s failed: %v", dir, err) } - rendered, err := renderAppDevTemplate(dir, filepath.Base(dir), tgz) + rendered, err := renderAppDevTemplate(dir, appDevProjectName(dir), tgz) if err != nil { return err } if err := writeMiaodaScaffoldFields(dir, template, version); err != nil { return err } + devPrefix := "" + if dir != "." { + devPrefix = "cd " + dir + " && " + } nextSteps := []string{ - fmt.Sprintf("cd %s && npm install && npm run dev", dir), + devPrefix + "npm install && npm run dev", "lark-cli apps +create --name to create the Miaoda app", "run lark-cli apps +app-dev-publish --app-id from the project root (saved into miaoda.json on success; later runs need no flag)", } diff --git a/shortcuts/apps/apps_app_dev_init_template_test.go b/shortcuts/apps/apps_app_dev_init_template_test.go index 89c55b44e5..02da97110a 100644 --- a/shortcuts/apps/apps_app_dev_init_template_test.go +++ b/shortcuts/apps/apps_app_dev_init_template_test.go @@ -49,14 +49,24 @@ func TestAppDevTemplatePackageName(t *testing.T) { } func TestResolveAppDevDir(t *testing.T) { - if got := resolveAppDevDir("", "react-standard-webapp"); got != filepath.Join(".", "react-standard-webapp") { - t.Errorf("default dir = %q", got) + if got := resolveAppDevDir(""); got != "." { + t.Errorf("default dir = %q, want . (in-place init)", got) } - if got := resolveAppDevDir("./my-app", "react-standard-webapp"); got != "./my-app" { + if got := resolveAppDevDir("./my-app"); got != "./my-app" { t.Errorf("explicit dir = %q", got) } } +func TestAppDevProjectName(t *testing.T) { + if got := appDevProjectName("./my-app"); got != "my-app" { + t.Errorf("subdir project name = %q", got) + } + // In-place: "." resolves to the real directory name, not ".". + if got := appDevProjectName("."); got == "." || got == "" { + t.Errorf("in-place project name = %q, want the cwd base name", got) + } +} + func TestValidateAppDevDir(t *testing.T) { for _, ok := range []string{"", "my-app", "./my-app", "a/b"} { if err := validateAppDevDir(ok); err != nil { @@ -681,8 +691,14 @@ func TestAppDevInitTemplateDryRun(t *testing.T) { if data["remote_side_effects"] != "read-only npm registry download, no Lark API" { t.Errorf("remote_side_effects = %v", data["remote_side_effects"]) } - if data["target_dir_state"] != "ok (absent or empty)" { - t.Errorf("target_dir_state = %v", data["target_dir_state"]) + if data["target_dir"] != "." { + t.Errorf("target_dir = %v, want . (in-place default)", data["target_dir"]) + } + // The test cwd (package dir) is non-empty, so the in-place default must + // surface as not usable in dry-run. + state, _ := data["target_dir_state"].(string) + if !strings.Contains(state, "not usable") || !strings.Contains(state, "current directory is not empty") { + t.Errorf("target_dir_state = %q", state) } } diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-template.md b/skills/lark-apps/references/lark-apps-app-dev-init-template.md index 57a7bfbf99..ea08a36040 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-init-template.md +++ b/skills/lark-apps/references/lark-apps-app-dev-init-template.md @@ -9,7 +9,7 @@ ## 命令骨架 - `--type` 与 `--template` 二选一:`--type frontend|full_stack` 用默认模板映射;`--template <短名>`(如 `vite-react`)直接指定模板包,优先于 `--type`——模板包名为 `@lark-apaas/coding-template-<短名>`。 -- 可选:`--dir`,相对路径,默认 `./<模板名>`;目录已存在且非空会被拒绝。 +- 可选:`--dir`,相对路径;**缺省就地初始化到当前目录**(须为空目录,项目名取目录名);传 `--dir ./my-app` 则创建子目录。 - 前置:已完成 `lark-cli config init`(框架级要求,纯本地命令也需要);本步骤**不需要 Node.js**。内部从 npm registry 只读下载模板包(主源 registry.npmmirror.com,失败自动降级 registry.npmjs.org 官方源) `@lark-apaas/coding-template-<模板名>` 并本地渲染,不执行任何远程脚本、不装依赖(秒级返回)。 ## 示例 From 92d823597f173b139da60f342f240ad4f305c427 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 16:36:30 +0800 Subject: [PATCH 18/51] fix(apps): resolve in-place project name without filepath.Abs --- shortcuts/apps/apps_app_dev_init_template.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_app_dev_init_template.go index 13e4056178..e4c16d3719 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -83,10 +83,13 @@ func resolveAppDevDir(dir string) string { // target directory: its base name, resolved to the real directory name when // scaffolding in place (base of "." is "."). func appDevProjectName(dir string) string { - if abs, err := filepath.Abs(dir); err == nil { - return filepath.Base(abs) + base := filepath.Base(dir) + if base == "." || base == string(filepath.Separator) { + if cwd, err := os.Getwd(); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard); read-only cwd lookup for the display-only project name. + return filepath.Base(cwd) + } } - return filepath.Base(dir) + return base } // validateAppDevDir rejects absolute paths and .. traversal in --dir, keeping From c90c5bafd2f9501f39e967bd1bff0baba1c0effa Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 16:49:55 +0800 Subject: [PATCH 19/51] fix(apps): emit raw JSON so next_steps keep literal characters --- shortcuts/apps/apps_app_dev_init_template.go | 2 +- shortcuts/apps/apps_app_dev_publish.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_app_dev_init_template.go index e4c16d3719..425bf402d5 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -232,7 +232,7 @@ var AppsAppDevInitTemplate = common.Shortcut{ "files": rendered.Files, "next_steps": nextSteps, } - rctx.OutFormat(data, nil, func(w io.Writer) { + rctx.OutFormatRaw(data, nil, func(w io.Writer) { fmt.Fprintf(w, "dir: %s\ntemplate: %s@%s\nfiles: %d\nnext steps:\n", dir, template, version, rendered.Files) for _, s := range nextSteps { fmt.Fprintf(w, " - %s\n", s) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 1d1fe3c1bf..5d949a0fb4 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -483,7 +483,7 @@ var AppsAppDevPublish = common.Shortcut{ } } } - rctx.OutFormat(data, nil, func(w io.Writer) { + rctx.OutFormatRaw(data, nil, func(w io.Writer) { fmt.Fprintf(w, "app_id: %s\nrelease_id: %s\nstatus: %s\n", appID, releaseID, status) if onlineURL != "" { fmt.Fprintf(w, "online_url: %s\n", onlineURL) From a8eabdfa3f7844a9cb0367b523e66a7de3c22229 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 17:01:57 +0800 Subject: [PATCH 20/51] feat(apps): adopt the artifact-hosting pre_release contract --- shortcuts/apps/apps_app_dev_publish.go | 23 ++++++++++++++------- shortcuts/apps/apps_app_dev_publish_test.go | 5 ++--- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 5d949a0fb4..1ab3c7d71d 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -29,6 +29,13 @@ import ( // layout; +app-dev-publish always publishes ./dist from the project root. const appDevDistDir = "dist" +// appDevUploadURLKey is the pre_release kv carrying the presigned TOS upload +// URL for the artifact-hosting chain (upload path is the server-side +// convention /artifact.zip, so no separate tos_path is handed down). +// The INNER_ prefix keeps it outside the MIAODA_ build-env allowlist — an +// upload credential must never reach the build subprocess. +const appDevUploadURLKey = "INNER_MIAODA_UPLOAD_URL" + // appDevEnvPrefix is the allowlist prefix for build env vars handed down by // pre_release. Only exact, case-sensitive MIAODA_* keys are injected into the // build subprocess — this is the security boundary that keeps a compromised @@ -332,7 +339,7 @@ var AppsAppDevPublish = common.Shortcut{ }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { dry := common.NewDryRunAPI(). - Desc("Read .spark/meta.json app_id -> GET pre_release (upload_url/tos_path + MIAODA_* build env) -> npm run build -> validate dist layout -> zip -> PUT to TOS -> POST releases; returns online_url (sync) or release_id (async)") + Desc("Resolve app id (miaoda.json / --app-id) -> GET pre_release (presigned upload URL + MIAODA_* build env) -> run build.command -> validate output layout -> zip -> PUT to TOS -> POST releases; returns online_url (sync) or release_id (async)") cfg, appID, fromFlag, err := resolveAppDevPublishTarget(rctx) if cfg == nil { cfg = &appDevProjectConfig{Source: miaodaJSONRelPath} @@ -349,9 +356,9 @@ var AppsAppDevPublish = common.Shortcut{ dry.Set("app_id_source", cfg.Source) } dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))). - PUT(" (https only, from pre_release kvs)"). + PUT(" (https only)"). POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))). - Body(map[string]string{"tos_path": ""}) + Body(map[string]string{}) } dry.Set("build_command", strings.Join(cfg.BuildCommand, " ")+" (from miaoda.json build.command, default npm run build; env allowlist: MIAODA_* keys from pre_release; skipped with --skip-build)") dry.Set("build_output", cfg.BuildOutput) @@ -387,9 +394,9 @@ var AppsAppDevPublish = common.Shortcut{ return withAppsHint(err, appIDListHint) } kvm := parsePreReleaseKVs(preData) - uploadURL, tosPath := kvm["upload_url"], kvm["tos_path"] - if uploadURL == "" || tosPath == "" { - return appsSubprocessEnvelopeError("pre_release kvs missing upload_url or tos_path") + uploadURL := kvm[appDevUploadURLKey] + if uploadURL == "" { + return appsSubprocessEnvelopeError("pre_release kvs missing %s", appDevUploadURLKey) } if u, perr := url.Parse(uploadURL); perr != nil || u.Scheme != "https" { return appsSubprocessEnvelopeError("pre_release upload_url is not https; refusing to upload") @@ -438,8 +445,10 @@ var AppsAppDevPublish = common.Shortcut{ return errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed: HTTP %d", resp.StatusCode) } + // The artifact-hosting release needs no body: the artifact location is + // the server-side convention behind the presigned upload URL. releasePath := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID)) - releaseData, err := rctx.CallAPITyped("POST", releasePath, nil, map[string]interface{}{"tos_path": tosPath}) + releaseData, err := rctx.CallAPITyped("POST", releasePath, nil, map[string]interface{}{}) if err != nil { return withAppsHint(err, "verify the app supports artifact-hosting publish; list your apps with `lark-cli apps +list`") } diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index 8f30d8a543..42b661b8fb 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -325,8 +325,7 @@ func newTOSTLSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { func stubPreRelease(reg *httpmock.Registry, appID, uploadURL string, extraKVs map[string]string) { kvs := []interface{}{ - map[string]interface{}{"key": "upload_url", "value": uploadURL}, - map[string]interface{}{"key": "tos_path", "value": "bucket/pkg.zip"}, + map[string]interface{}{"key": "INNER_MIAODA_UPLOAD_URL", "value": uploadURL}, } for k, v := range extraKVs { kvs = append(kvs, map[string]interface{}{"key": k, "value": v}) @@ -601,7 +600,7 @@ func TestAppDevPublishExecute_PreReleaseMissingKVs(t *testing.T) { }) err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryInternal) - if !strings.Contains(p.Message, "missing upload_url or tos_path") { + if !strings.Contains(p.Message, "missing INNER_MIAODA_UPLOAD_URL") { t.Errorf("message = %q", p.Message) } } From af7afe33cf1daa2e1eac39e0a58415f5f2f3ab39 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 17:22:11 +0800 Subject: [PATCH 21/51] feat(apps): ignore non-protocol build artifacts instead of rejecting --- shortcuts/apps/apps_app_dev_publish.go | 74 ++++++++++++--------- shortcuts/apps/apps_app_dev_publish_test.go | 51 ++++++++++---- 2 files changed, 82 insertions(+), 43 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 1ab3c7d71d..96af77a693 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -91,24 +91,27 @@ func ensureMetaOnlineURL(dir, onlineURL string) error { } // validateAppDevDist walks the dist directory and enforces the -// artifact-hosting layout: output/{index.html,routes.json} required, -// output_resource/ optional, nothing else at the top level. Returns the -// candidates for zip packing. allowSensitive skips the credential-file scan. -func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) ([]htmlPublishCandidate, error) { - candidates, err := walkHTMLPublishCandidates(fio, distPath) +// artifact-hosting layout on what the protocol consumes: output/ must hold at +// least one .html plus a valid routes.json; output_resource/ and +// output_capabilities/ ride along. Anything else at the top level is ignored +// (build tools commonly emit extra artifacts next to the protocol dirs) and +// reported via ignored for the caller to surface. Only the returned +// candidates are packed and scanned. allowSensitive skips the +// credential-file scan. +func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) (candidates []htmlPublishCandidate, ignored []string, err error) { + all, err := walkHTMLPublishCandidates(fio, distPath) if err != nil { // A missing dist directory means "build first", not a bad flag value. if errors.Is(err, fs.ErrNotExist) { - return nil, appsFailedPreconditionError( + return nil, nil, appsFailedPreconditionError( "dist directory not found; the artifact-hosting layout expects ./dist"). WithHint("run npm run build first, or drop --skip-build to let the command build") } - return nil, err + return nil, nil, err } var hasHTML, hasRoutes, hasOutput bool - var extras []string - seenExtras := map[string]bool{} - for _, c := range candidates { + seenIgnored := map[string]bool{} + for _, c := range all { top := c.RelPath if i := strings.IndexByte(top, '/'); i >= 0 { top = top[:i] @@ -122,38 +125,36 @@ func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) if c.RelPath == "output/routes.json" { hasRoutes = true } + candidates = append(candidates, c) case "output_resource", "output_capabilities": // output_resource ships to CDN; output_capabilities is the // platform-capability placeholder — both ride along in the zip. + candidates = append(candidates, c) default: - if !seenExtras[top] { - seenExtras[top] = true - extras = append(extras, top) + // Extra build artifacts next to the protocol dirs are none of the + // CLI's business — pick what the protocol needs, skip the rest. + if !seenIgnored[top] { + seenIgnored[top] = true + ignored = append(ignored, top) } } } - if len(extras) > 0 { - sort.Strings(extras) - return nil, appsValidationError( - "the build output contains %d top-level entr(ies) outside the artifact-hosting layout: %s", - len(extras), truncatedJoin(extras, maxSensitiveListInError)). - WithHint("only output/, output_resource/ and output_capabilities/ are uploaded; adjust the build output") - } + sort.Strings(ignored) if !hasOutput { - return nil, appsFailedPreconditionError( + return nil, ignored, appsFailedPreconditionError( "the build output is missing the output/ directory required by the artifact-hosting layout"). WithHint("run the build first; expected layout: /output/{*.html,routes.json} + output_resource/") } if !hasHTML { - return nil, appsFailedPreconditionError("output/ has no .html file; the protocol requires at least one (an SPA entry must be named index.html)"). + return nil, ignored, appsFailedPreconditionError("output/ has no .html file; the protocol requires at least one (an SPA entry must be named index.html)"). WithHint("check the build config: HTML entries belong in output/, hashed assets in output_resource/") } if !hasRoutes { - return nil, appsFailedPreconditionError("output/routes.json is missing"). + return nil, ignored, appsFailedPreconditionError("output/routes.json is missing"). WithHint("routes.json is required for content review routing; official templates generate it during the build") } if err := validateAppDevRoutesJSON(distPath); err != nil { - return nil, err + return nil, ignored, err } if !allowSensitive { var hits []string @@ -163,10 +164,10 @@ func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) } } if len(hits) > 0 { - return nil, appDevSensitiveCandidatesError(hits) + return nil, ignored, appDevSensitiveCandidatesError(hits) } } - return candidates, nil + return candidates, ignored, nil } // appDevRoutesJSON is the routes.json v1 schema (fallback-only object). All @@ -314,10 +315,17 @@ var AppsAppDevPublish = common.Shortcut{ // missing) are not fatal here; DryRun/Execute surface them with // richer context. if !rctx.Bool("allow-sensitive") { - if candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), appDevDistDir); err == nil { + if candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), cfg.BuildOutput); err == nil { var hits []string for _, c := range candidates { - if isSensitiveCandidate(appDevDistDir, c) { + top := c.RelPath + if i := strings.IndexByte(top, '/'); i >= 0 { + top = top[:i] + } + if top != "output" && top != "output_resource" && top != "output_capabilities" { + continue // not uploaded, not scanned + } + if isSensitiveCandidate(cfg.BuildOutput, c) { hits = append(hits, c.RelPath) } } @@ -366,8 +374,10 @@ var AppsAppDevPublish = common.Shortcut{ dry.Set("dist_state", "missing or unreadable: "+err.Error()) } else { dry.Set("dist_file_count", len(candidates)) - if _, verr := validateAppDevDist(rctx.FileIO(), cfg.BuildOutput, rctx.Bool("allow-sensitive")); verr != nil { + if _, dryIgnored, verr := validateAppDevDist(rctx.FileIO(), cfg.BuildOutput, rctx.Bool("allow-sensitive")); verr != nil { dry.Set("dist_validation_error", verr.Error()) + } else if len(dryIgnored) > 0 { + dry.Set("dist_ignored_entries", strings.Join(dryIgnored, ", ")) } } return dry @@ -417,10 +427,14 @@ var AppsAppDevPublish = common.Shortcut{ built = true } - candidates, err := validateAppDevDist(rctx.FileIO(), cfg.BuildOutput, rctx.Bool("allow-sensitive")) + candidates, ignoredEntries, err := validateAppDevDist(rctx.FileIO(), cfg.BuildOutput, rctx.Bool("allow-sensitive")) if err != nil { return err } + if len(ignoredEntries) > 0 { + fmt.Fprintf(rctx.IO().ErrOut, "skipping %d top-level entr(ies) outside the protocol layout: %s\n", + len(ignoredEntries), strings.Join(ignoredEntries, ", ")) + } zipball, err := buildAppDevZip(rctx.FileIO(), candidates) if err != nil { return err diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index 42b661b8fb..9c3a2ce62d 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -107,17 +107,17 @@ func TestValidateAppDevDist(t *testing.T) { {"ok no resource", []string{"output/index.html", "output/routes.json"}, ""}, {"ok non-index html", []string{"output/page.html", "output/routes.json"}, ""}, {"ok capabilities dir", []string{"output/index.html", "output/routes.json", "output_capabilities/cap.json"}, ""}, - {"no output dir", []string{"stray.txt"}, "top-level entr"}, + {"ok extra top-level dir ignored", []string{"output/index.html", "output/routes.json", "client/x.js"}, ""}, + {"ok extra top-level file ignored", []string{"output/index.html", "output/routes.json", "notes.md"}, ""}, + {"only strays, no output dir", []string{"stray.txt"}, "missing the output/ directory"}, {"no html", []string{"output/routes.json"}, "no .html file"}, {"no routes", []string{"output/index.html"}, "routes.json"}, - {"extra top-level dir", []string{"output/index.html", "output/routes.json", "extra/x.js"}, "outside the artifact-hosting layout"}, - {"extra top-level file", []string{"output/index.html", "output/routes.json", "notes.md"}, "outside the artifact-hosting layout"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dist := filepath.Join(t.TempDir(), "dist") writeDistFiles(t, dist, tt.files) - _, err := validateAppDevDist(permissiveFIO{}, dist, false) + _, _, err := validateAppDevDist(permissiveFIO{}, dist, false) if tt.wantErr == "" { if err != nil { t.Errorf("want valid, got %v", err) @@ -131,28 +131,53 @@ func TestValidateAppDevDist(t *testing.T) { } } +func TestValidateAppDevDist_IgnoresExtrasAndExcludesFromPack(t *testing.T) { + dist := filepath.Join(t.TempDir(), "dist") + writeDistFiles(t, dist, []string{ + "output/index.html", "output/routes.json", + "client/bundle.js", "server/main.js", "stats.json", + }) + candidates, ignored, err := validateAppDevDist(permissiveFIO{}, dist, false) + if err != nil { + t.Fatal(err) + } + if len(ignored) != 3 { + t.Errorf("ignored = %v, want client/server/stats.json tops", ignored) + } + for _, c := range candidates { + if !strings.HasPrefix(c.RelPath, "output/") { + t.Errorf("candidate outside protocol dirs must not be packed: %s", c.RelPath) + } + } + // Sensitive files in ignored dirs are not shipped, hence not scanned. + writeDistFiles(t, dist, []string{"client/.env"}) + if _, _, err := validateAppDevDist(permissiveFIO{}, dist, false); err != nil { + t.Errorf("sensitive file in an ignored dir must not block: %v", err) + } +} + func TestValidateAppDevDist_RoutesSchema(t *testing.T) { dist := filepath.Join(t.TempDir(), "dist") writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json"}) // Broken JSON. os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte("not-json"), 0o644) - if _, err := validateAppDevDist(permissiveFIO{}, dist, false); err == nil || !strings.Contains(err.Error(), "not valid JSON") { + if _, _, err := validateAppDevDist(permissiveFIO{}, dist, false); err == nil || !strings.Contains(err.Error(), "not valid JSON") { t.Errorf("broken routes.json must be rejected, got %v", err) } // Missing required fields. os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte(`{"version":1}`), 0o644) - if _, err := validateAppDevDist(permissiveFIO{}, dist, false); err == nil || !strings.Contains(err.Error(), "missing required fields") { + if _, _, err := validateAppDevDist(permissiveFIO{}, dist, false); err == nil || !strings.Contains(err.Error(), "missing required fields") { t.Errorf("incomplete routes.json must be rejected, got %v", err) } // Unknown fields ignored (forward compatible). os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte(`{"version":1,"type":"t","fallback":"index.html","future":true}`), 0o644) - if _, err := validateAppDevDist(permissiveFIO{}, dist, false); err != nil { + if _, _, err := validateAppDevDist(permissiveFIO{}, dist, false); err != nil { t.Errorf("unknown fields must be ignored: %v", err) } } func TestValidateAppDevDist_Missing(t *testing.T) { - _, err := validateAppDevDist(permissiveFIO{}, filepath.Join(t.TempDir(), "dist"), false) + _, _, err := validateAppDevDist(permissiveFIO{}, filepath.Join(t.TempDir(), "dist"), false) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition { t.Errorf("subtype = %q, want failed_precondition", p.Subtype) @@ -165,11 +190,11 @@ func TestValidateAppDevDist_Missing(t *testing.T) { func TestValidateAppDevDist_Sensitive(t *testing.T) { dist := filepath.Join(t.TempDir(), "dist") writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json", "output/.env"}) - _, err := validateAppDevDist(permissiveFIO{}, dist, false) + _, _, err := validateAppDevDist(permissiveFIO{}, dist, false) if err == nil || !strings.Contains(err.Error(), "credential file") { t.Errorf("sensitive file must be rejected, got %v", err) } - if _, err := validateAppDevDist(permissiveFIO{}, dist, true); err != nil { + if _, _, err := validateAppDevDist(permissiveFIO{}, dist, true); err != nil { t.Errorf("allow-sensitive must waive the scan: %v", err) } } @@ -179,7 +204,7 @@ func TestValidateAppDevDist_Sensitive(t *testing.T) { func TestBuildAppDevZip(t *testing.T) { dist := filepath.Join(t.TempDir(), "dist") writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json", "output_resource/a.js"}) - candidates, err := validateAppDevDist(permissiveFIO{}, dist, false) + candidates, _, err := validateAppDevDist(permissiveFIO{}, dist, false) if err != nil { t.Fatal(err) } @@ -208,7 +233,7 @@ func TestBuildAppDevZip_RawSizeCap(t *testing.T) { t.Cleanup(func() { maxAppDevPublishRawBytes = orig }) dist := filepath.Join(t.TempDir(), "dist") writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json"}) - candidates, err := validateAppDevDist(permissiveFIO{}, dist, false) + candidates, _, err := validateAppDevDist(permissiveFIO{}, dist, false) if err != nil { t.Fatal(err) } @@ -223,7 +248,7 @@ func TestBuildAppDevZip_ZipSizeCap(t *testing.T) { t.Cleanup(func() { maxAppDevPublishZipBytes = orig }) dist := filepath.Join(t.TempDir(), "dist") writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json"}) - candidates, err := validateAppDevDist(permissiveFIO{}, dist, false) + candidates, _, err := validateAppDevDist(permissiveFIO{}, dist, false) if err != nil { t.Fatal(err) } From c80150894eadf0d9b2ea808f3220b9633ca469a1 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 17:22:36 +0800 Subject: [PATCH 22/51] docs(apps): note non-protocol artifacts are skipped on publish --- skills/lark-apps/references/lark-apps-app-dev-publish.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-apps/references/lark-apps-app-dev-publish.md b/skills/lark-apps/references/lark-apps-app-dev-publish.md index f60032218a..a68b4a9dd0 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-publish.md +++ b/skills/lark-apps/references/lark-apps-app-dev-publish.md @@ -12,7 +12,7 @@ - `--app-id` 可选:首次发布传它指定目标(成功后自动写入 `miaoda.json` 的 app 段,后续免传);已记录 app id 时可省略;**两者都有且不一致会被拒绝**(防误发错目标),确要切换先更新 miaoda.json。 - 可选:`--skip-build`(跳过 `npm run build`,直接发布已有 `./dist`)、`--allow-sensitive`(跳过凭据文件扫描)。 - 内部流程:读 miaoda.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → 执行 `build.command`(缺省 `npm run build`,argv 直接执行不走 shell,自动注入变量)→ 校验产物协议 → zip 上传 → 触发发布。 -- 产物协议(详见《妙搭产物托管协议规范》):`output/` 必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(`{"version":1,"type":"","fallback":"index.html"}`);`output_resource/`、`output_capabilities/` 可选;顶层不允许其他条目。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 +- 产物协议(详见《妙搭产物托管协议规范》):`output/` 必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(`{"version":1,"type":"","fallback":"index.html"}`);`output_resource/`、`output_capabilities/` 可选;顶层其他条目**自动忽略不上传**(stderr 会列出跳过项)。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 ## 示例 From 99957ad961b0fcc8748860c3246dcd8d1d012095 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 17:28:55 +0800 Subject: [PATCH 23/51] fix(apps): validate routes.json as a route enumeration array --- shortcuts/apps/apps_app_dev_publish.go | 45 ++++++++++++------- shortcuts/apps/apps_app_dev_publish_test.go | 43 +++++++++++------- .../references/lark-apps-app-dev-publish.md | 2 +- 3 files changed, 57 insertions(+), 33 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 96af77a693..fa6b276721 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -170,30 +170,43 @@ func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) return candidates, ignored, nil } -// appDevRoutesJSON is the routes.json v1 schema (fallback-only object). All -// three fields are MUST per the artifact-hosting protocol; unknown fields are -// ignored for forward compatibility. -type appDevRoutesJSON struct { - Version int `json:"version"` - Type string `json:"type"` - Fallback string `json:"fallback"` +// appDevRoute is one entry of the routes.json route enumeration consumed by +// TNS security scanning: path is required (leading /, no base prefix, may +// hold :param segments); file/name are optional; unknown fields are ignored +// for forward compatibility. +type appDevRoute struct { + Path string `json:"path"` } -// validateAppDevRoutesJSON light-checks output/routes.json so schema problems -// fail at publish time instead of bouncing off content review later. +// appDevRoutesHint is the actionable schema reminder for routes.json errors. +const appDevRoutesHint = `routes.json must be a route enumeration array, e.g. [{"path":"/","file":"index.html"}] (empty [] is allowed for a static site); it feeds security scanning, so it must match the real routes` + +// validateAppDevRoutesJSON light-checks output/routes.json against the +// route-enumeration schema so problems fail at publish time instead of +// bouncing off the TNS scan later: top level must be an array, every entry +// needs a /-prefixed path, and paths must be unique. func validateAppDevRoutesJSON(distPath string) error { b, err := os.ReadFile(filepath.Join(distPath, "output", "routes.json")) //nolint:forbidigo // path is under the walked build output. if err != nil { return appsFileIOError(err, "read output/routes.json failed: %v", err) } - var r appDevRoutesJSON - if err := json.Unmarshal(b, &r); err != nil { - return appsFailedPreconditionError("output/routes.json is not valid JSON: %v", err). - WithHint(`expected schema: {"version":1,"type":"","fallback":"index.html"}`) + var routes []appDevRoute + if err := json.Unmarshal(b, &routes); err != nil { + return appsFailedPreconditionError("output/routes.json is not a valid route enumeration array: %v", err). + WithHint(appDevRoutesHint) } - if r.Version == 0 || strings.TrimSpace(r.Type) == "" || strings.TrimSpace(r.Fallback) == "" { - return appsFailedPreconditionError("output/routes.json is missing required fields (version/type/fallback are all required)"). - WithHint(`expected schema: {"version":1,"type":"","fallback":"index.html"}`) + seen := make(map[string]bool, len(routes)) + for i, r := range routes { + path := strings.TrimSpace(r.Path) + if path == "" || !strings.HasPrefix(path, "/") { + return appsFailedPreconditionError("output/routes.json entry %d has an invalid path %q (required, must start with /, no base prefix)", i, r.Path). + WithHint(appDevRoutesHint) + } + if seen[path] { + return appsFailedPreconditionError("output/routes.json has duplicate path %q (paths must be unique)", path). + WithHint(appDevRoutesHint) + } + seen[path] = true } return nil } diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index 9c3a2ce62d..7efc06fb99 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -89,7 +89,7 @@ func writeDistFiles(t *testing.T, base string, files []string) { } body := "x" if strings.HasSuffix(f, "routes.json") { - body = `{"version":1,"type":"test-stack","fallback":"index.html"}` + body = `[{"path":"/","file":"index.html"}]` } if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) @@ -159,21 +159,32 @@ func TestValidateAppDevDist_IgnoresExtrasAndExcludesFromPack(t *testing.T) { func TestValidateAppDevDist_RoutesSchema(t *testing.T) { dist := filepath.Join(t.TempDir(), "dist") writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json"}) - // Broken JSON. - os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte("not-json"), 0o644) - if _, _, err := validateAppDevDist(permissiveFIO{}, dist, false); err == nil || !strings.Contains(err.Error(), "not valid JSON") { - t.Errorf("broken routes.json must be rejected, got %v", err) - } - // Missing required fields. - os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte(`{"version":1}`), 0o644) - if _, _, err := validateAppDevDist(permissiveFIO{}, dist, false); err == nil || !strings.Contains(err.Error(), "missing required fields") { - t.Errorf("incomplete routes.json must be rejected, got %v", err) - } - // Unknown fields ignored (forward compatible). - os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte(`{"version":1,"type":"t","fallback":"index.html","future":true}`), 0o644) - if _, _, err := validateAppDevDist(permissiveFIO{}, dist, false); err != nil { - t.Errorf("unknown fields must be ignored: %v", err) + set := func(body string) { + os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte(body), 0o644) + } + check := func(body, wantErr string) { + t.Helper() + set(body) + _, _, err := validateAppDevDist(permissiveFIO{}, dist, false) + if wantErr == "" { + if err != nil { + t.Errorf("routes %q should be valid: %v", body, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), wantErr) { + t.Errorf("routes %q: err = %v, want containing %q", body, err, wantErr) + } } + check("not-json", "not a valid route enumeration array") + // Old fallback-only object form is no longer the schema. + check(`{"version":1,"type":"t","fallback":"index.html"}`, "not a valid route enumeration array") + check(`[{"path":""}]`, "invalid path") + check(`[{"path":"orders"}]`, "invalid path") + check(`[{"path":"/"},{"path":"/"}]`, "duplicate path") + check(`[]`, "") // 纯静态站可为空数组 + check(`[{"path":"/orders/:id"}]`, "") // 动态段合法 + check(`[{"path":"/","file":"index.html","name":"首页","future":1}]`, "") // 未识别字段忽略 } func TestValidateAppDevDist_Missing(t *testing.T) { @@ -689,7 +700,7 @@ func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) f := &fakeEnvRunner{sideEffect: func() { writeDistFiles(t, filepath.Join(root, "public"), []string{"output/index.html", "output/routes.json"}) - routes := `{"version":1,"type":"custom-webapp","fallback":"index.html"}` + routes := `[{"path":"/","file":"index.html"}]` os.WriteFile(filepath.Join(root, "public", "output", "routes.json"), []byte(routes), 0o644) }} withFakeEnvRunner(t, f) diff --git a/skills/lark-apps/references/lark-apps-app-dev-publish.md b/skills/lark-apps/references/lark-apps-app-dev-publish.md index a68b4a9dd0..db3aa84444 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-publish.md +++ b/skills/lark-apps/references/lark-apps-app-dev-publish.md @@ -12,7 +12,7 @@ - `--app-id` 可选:首次发布传它指定目标(成功后自动写入 `miaoda.json` 的 app 段,后续免传);已记录 app id 时可省略;**两者都有且不一致会被拒绝**(防误发错目标),确要切换先更新 miaoda.json。 - 可选:`--skip-build`(跳过 `npm run build`,直接发布已有 `./dist`)、`--allow-sensitive`(跳过凭据文件扫描)。 - 内部流程:读 miaoda.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → 执行 `build.command`(缺省 `npm run build`,argv 直接执行不走 shell,自动注入变量)→ 校验产物协议 → zip 上传 → 触发发布。 -- 产物协议(详见《妙搭产物托管协议规范》):`output/` 必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(`{"version":1,"type":"","fallback":"index.html"}`);`output_resource/`、`output_capabilities/` 可选;顶层其他条目**自动忽略不上传**(stderr 会列出跳过项)。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 +- 产物协议(详见《妙搭产物托管协议规范》):`output/` 必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(**路由枚举数组**,如 `[{"path":"/","file":"index.html"}]`,纯静态站可为空数组;它是安全扫描的输入,必须与真实路由一致);`output_resource/`、`output_capabilities/` 可选;顶层其他条目**自动忽略不上传**(stderr 会列出跳过项)。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 ## 示例 From 032ffde419ef4083e701947051d9991f3bbc09a2 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 17:37:14 +0800 Subject: [PATCH 24/51] feat(apps): rename pre_release upload key to artifact_url --- shortcuts/apps/apps_app_dev_publish.go | 6 +++--- shortcuts/apps/apps_app_dev_publish_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index fa6b276721..e31409ce67 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -32,9 +32,9 @@ const appDevDistDir = "dist" // appDevUploadURLKey is the pre_release kv carrying the presigned TOS upload // URL for the artifact-hosting chain (upload path is the server-side // convention /artifact.zip, so no separate tos_path is handed down). -// The INNER_ prefix keeps it outside the MIAODA_ build-env allowlist — an -// upload credential must never reach the build subprocess. -const appDevUploadURLKey = "INNER_MIAODA_UPLOAD_URL" +// The name stays outside the MIAODA_ build-env allowlist — an upload +// credential must never reach the build subprocess. +const appDevUploadURLKey = "artifact_url" // appDevEnvPrefix is the allowlist prefix for build env vars handed down by // pre_release. Only exact, case-sensitive MIAODA_* keys are injected into the diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index 7efc06fb99..35b36042a0 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -361,7 +361,7 @@ func newTOSTLSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { func stubPreRelease(reg *httpmock.Registry, appID, uploadURL string, extraKVs map[string]string) { kvs := []interface{}{ - map[string]interface{}{"key": "INNER_MIAODA_UPLOAD_URL", "value": uploadURL}, + map[string]interface{}{"key": "artifact_url", "value": uploadURL}, } for k, v := range extraKVs { kvs = append(kvs, map[string]interface{}{"key": k, "value": v}) @@ -636,7 +636,7 @@ func TestAppDevPublishExecute_PreReleaseMissingKVs(t *testing.T) { }) err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryInternal) - if !strings.Contains(p.Message, "missing INNER_MIAODA_UPLOAD_URL") { + if !strings.Contains(p.Message, "missing artifact_url") { t.Errorf("message = %q", p.Message) } } From ef067f568e34245cc73df876683b7049b3580200 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 17:48:50 +0800 Subject: [PATCH 25/51] feat(apps): support pinning template version for init-template --- shortcuts/apps/app_dev_template_fetch.go | 49 +++++++++++++------ shortcuts/apps/apps_app_dev_init_template.go | 8 ++- .../apps/apps_app_dev_init_template_test.go | 38 +++++++++++--- .../lark-apps-app-dev-init-template.md | 1 + 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/shortcuts/apps/app_dev_template_fetch.go b/shortcuts/apps/app_dev_template_fetch.go index dd2f134c9e..cdf3a542b0 100644 --- a/shortcuts/apps/app_dev_template_fetch.go +++ b/shortcuts/apps/app_dev_template_fetch.go @@ -16,6 +16,7 @@ import ( "os" "path" "path/filepath" + "sort" "strings" "github.com/larksuite/cli/errs" @@ -63,15 +64,16 @@ type npmPackageMeta struct { } // fetchAppDevTemplate resolves and downloads the template package, trying -// each registry in appDevRegistries until one succeeds. onFallback is called -// with a human-readable note before each retry (nil to skip). -func fetchAppDevTemplate(ctx context.Context, pkg string, onFallback func(note string)) (version string, tgz []byte, err error) { +// each registry in appDevRegistries until one succeeds. requested pins a +// specific version or dist-tag ("" = latest). onFallback is called with a +// human-readable note before each retry (nil to skip). +func fetchAppDevTemplate(ctx context.Context, pkg, requested string, onFallback func(note string)) (version string, tgz []byte, err error) { var lastErr error for i, base := range appDevRegistries { if i > 0 && onFallback != nil { onFallback(strings.TrimRight(appDevRegistries[i-1], "/") + " failed, falling back to " + strings.TrimRight(base, "/")) } - v, tarballURL, err := fetchAppDevTemplateMeta(ctx, base, pkg) + v, tarballURL, err := fetchAppDevTemplateMeta(ctx, base, pkg, requested) if err != nil { lastErr = err continue @@ -90,10 +92,11 @@ func fetchAppDevTemplate(ctx context.Context, pkg string, onFallback func(note s return "", nil, lastErr } -// fetchAppDevTemplateMeta resolves the template package's latest version and -// tarball URL from one npm registry. Only https tarball URLs on the same -// registry host are accepted. -func fetchAppDevTemplateMeta(ctx context.Context, registryBase, pkg string) (version, tarballURL string, err error) { +// fetchAppDevTemplateMeta resolves the template package's version and +// tarball URL from one npm registry. requested may be a dist-tag (checked +// first) or an exact version; "" means the latest dist-tag. Only https +// tarball URLs on the same registry host are accepted. +func fetchAppDevTemplateMeta(ctx context.Context, registryBase, pkg, requested string) (version, tarballURL string, err error) { metaURL := strings.TrimRight(registryBase, "/") + "/" + pkg body, err := appDevHTTPGet(ctx, metaURL, appDevMaxTemplateTgzBytes, "the template package may not be published yet; ask the artifact team, or check network/registry access") @@ -104,17 +107,31 @@ func fetchAppDevTemplateMeta(ctx context.Context, registryBase, pkg string) (ver if err := json.Unmarshal(body, &meta); err != nil { return "", "", appsSubprocessEnvelopeError("npm registry metadata for %s is not valid JSON", pkg) } - latest := meta.DistTags["latest"] - if latest == "" { - return "", "", appsSubprocessEnvelopeError("npm registry metadata for %s has no latest dist-tag", pkg) + resolved := strings.TrimSpace(requested) + if resolved == "" { + resolved = "latest" } - v, ok := meta.Versions[latest] - if !ok || v.Dist.Tarball == "" { - return "", "", appsSubprocessEnvelopeError("npm registry metadata for %s@%s has no tarball URL", pkg, latest) + // A dist-tag wins over a literal version of the same name (mirrors npm). + if tagged := meta.DistTags[resolved]; tagged != "" { + resolved = tagged + } + v, ok := meta.Versions[resolved] + if !ok { + tags := make([]string, 0, len(meta.DistTags)) + for t := range meta.DistTags { + tags = append(tags, t) + } + sort.Strings(tags) + return "", "", errs.NewNetworkError(errs.SubtypeNetworkTransport, + "npm registry has no version or dist-tag %q for %s", requested, pkg). + WithHint("pass an exact published version or a dist-tag with --template-version; available dist-tags: " + strings.Join(tags, ", ")) + } + if v.Dist.Tarball == "" { + return "", "", appsSubprocessEnvelopeError("npm registry metadata for %s@%s has no tarball URL", pkg, resolved) } u, perr := url.Parse(v.Dist.Tarball) if perr != nil || u.Scheme != "https" { - return "", "", appsSubprocessEnvelopeError("npm registry tarball URL for %s@%s is not https; refusing to download", pkg, latest) + return "", "", appsSubprocessEnvelopeError("npm registry tarball URL for %s@%s is not https; refusing to download", pkg, resolved) } // Same-origin constraint: npm registries serve tarballs from the registry // host itself, so a cross-host URL in the metadata is a red flag (metadata @@ -122,7 +139,7 @@ func fetchAppDevTemplateMeta(ctx context.Context, registryBase, pkg string) (ver if reg, rerr := url.Parse(registryBase); rerr != nil || u.Host != reg.Host { return "", "", appsSubprocessEnvelopeError("npm registry tarball URL host %q differs from registry host; refusing to download", u.Host) } - return latest, v.Dist.Tarball, nil + return resolved, v.Dist.Tarball, nil } // appDevHTTPGet fetches a URL with a hard size cap. notFoundHint decorates the diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_app_dev_init_template.go index 425bf402d5..3c17f00b65 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -157,6 +157,7 @@ var AppsAppDevInitTemplate = common.Shortcut{ Flags: []common.Flag{ {Name: "type", Desc: "app type; maps to a template package (frontend=react-standard-webapp, full_stack=react-express-standard-fullstack); ignored when --template is set", Enum: []string{"frontend", "full_stack"}}, {Name: "template", Desc: "template short name to use directly (resolves to @lark-apaas/coding-template-); takes precedence over --type"}, + {Name: "template-version", Desc: "template package version or dist-tag to pin (e.g. 0.1.0-alpha.20260827082008 or alpha); default: latest"}, {Name: "dir", Desc: "target directory, relative path (default: current directory, scaffolding in place); must be empty or new"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -178,6 +179,11 @@ var AppsAppDevInitTemplate = common.Shortcut{ } dry.Set("target_dir", dir) dry.Set("template", template) + if tv := strings.TrimSpace(rctx.Str("template-version")); tv != "" { + dry.Set("template_version", tv) + } else { + dry.Set("template_version", "latest") + } // Surface the same precondition the real run enforces, so a dry-run // on a non-empty target does not read as "would succeed". if err := ensureAppDevDirUsable(dir); err != nil { @@ -199,7 +205,7 @@ var AppsAppDevInitTemplate = common.Shortcut{ } pkg := appDevTemplatePackageName(template) fmt.Fprintf(rctx.IO().ErrOut, "fetching template package %s...\n", pkg) - version, tgz, err := fetchAppDevTemplate(ctx, pkg, func(note string) { + version, tgz, err := fetchAppDevTemplate(ctx, pkg, strings.TrimSpace(rctx.Str("template-version")), func(note string) { fmt.Fprintf(rctx.IO().ErrOut, "registry %s\n", note) }) if err != nil { diff --git a/shortcuts/apps/apps_app_dev_init_template_test.go b/shortcuts/apps/apps_app_dev_init_template_test.go index 02da97110a..80577cc290 100644 --- a/shortcuts/apps/apps_app_dev_init_template_test.go +++ b/shortcuts/apps/apps_app_dev_init_template_test.go @@ -168,11 +168,14 @@ func withFakeRegistry(t *testing.T, pkg string, tgz []byte) *httptest.Server { var srv *httptest.Server mux.HandleFunc("/"+pkg, func(w http.ResponseWriter, _ *http.Request) { meta := map[string]interface{}{ - "dist-tags": map[string]string{"latest": "1.2.3"}, + "dist-tags": map[string]string{"latest": "1.2.3", "alpha": "2.0.0-alpha.1"}, "versions": map[string]interface{}{ "1.2.3": map[string]interface{}{ "dist": map[string]string{"tarball": srv.URL + "/tarball.tgz"}, }, + "2.0.0-alpha.1": map[string]interface{}{ + "dist": map[string]string{"tarball": srv.URL + "/tarball.tgz"}, + }, }, } _ = json.NewEncoder(w).Encode(meta) @@ -189,6 +192,26 @@ func withFakeRegistry(t *testing.T, pkg string, tgz []byte) *httptest.Server { return srv } +func TestFetchAppDevTemplate_PinnedVersionAndTag(t *testing.T) { + pkg := "@lark-apaas/coding-template-react-standard-webapp" + withFakeRegistry(t, pkg, buildTemplateTgz(t, defaultTemplateEntries())) + // dist-tag resolution. + v, _, err := fetchAppDevTemplate(context.Background(), pkg, "alpha", nil) + if err != nil || v != "2.0.0-alpha.1" { + t.Errorf("dist-tag pin: v=%q err=%v", v, err) + } + // Exact version resolution. + v, _, err = fetchAppDevTemplate(context.Background(), pkg, "1.2.3", nil) + if err != nil || v != "1.2.3" { + t.Errorf("exact pin: v=%q err=%v", v, err) + } + // Unknown version: actionable error listing dist-tags. + _, _, err = fetchAppDevTemplate(context.Background(), pkg, "9.9.9", nil) + if err == nil || !strings.Contains(err.Error(), `no version or dist-tag "9.9.9"`) { + t.Errorf("unknown pin: err=%v", err) + } +} + // --- render tests --- func TestRenderAppDevTemplate(t *testing.T) { @@ -336,7 +359,7 @@ func TestFetchAppDevTemplateMeta_RejectsNonHTTPSTarball(t *testing.T) { appDevNewTransferClient = func() *http.Client { return srv.Client() } t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) - _, _, err := fetchAppDevTemplateMeta(context.Background(), srv.URL, pkg) + _, _, err := fetchAppDevTemplateMeta(context.Background(), srv.URL, pkg, "") if err == nil || !strings.Contains(err.Error(), "not https") { t.Errorf("non-https tarball must be rejected, got %v", err) } @@ -355,7 +378,7 @@ func TestFetchAppDevTemplateMeta_RejectsCrossHostTarball(t *testing.T) { appDevNewTransferClient = func() *http.Client { return srv.Client() } t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) - _, _, err := fetchAppDevTemplateMeta(context.Background(), srv.URL, pkg) + _, _, err := fetchAppDevTemplateMeta(context.Background(), srv.URL, pkg, "") if err == nil || !strings.Contains(err.Error(), "differs from registry host") { t.Errorf("cross-host tarball must be rejected, got %v", err) } @@ -378,7 +401,7 @@ func TestFetchAppDevTemplateMeta_404(t *testing.T) { appDevNewTransferClient = func() *http.Client { return srv.Client() } t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) - _, _, err := fetchAppDevTemplateMeta(context.Background(), srv.URL, "@lark-apaas/coding-template-x") + _, _, err := fetchAppDevTemplateMeta(context.Background(), srv.URL, "@lark-apaas/coding-template-x", "") p := requireAppsProblem(t, err, errs.CategoryNetwork) if !strings.Contains(p.Hint, "not be published") { t.Errorf("404 hint = %q", p.Hint) @@ -422,7 +445,7 @@ func TestFetchAppDevTemplate_FallbackOn5xx(t *testing.T) { pkg := "@lark-apaas/coding-template-react-standard-webapp" newFailingThenOKRegistries(t, pkg, buildTemplateTgz(t, defaultTemplateEntries()), 503) var notes []string - version, tgz, err := fetchAppDevTemplate(context.Background(), pkg, func(n string) { notes = append(notes, n) }) + version, tgz, err := fetchAppDevTemplate(context.Background(), pkg, "", func(n string) { notes = append(notes, n) }) if err != nil { t.Fatalf("fallback should succeed: %v", err) } @@ -439,7 +462,7 @@ func TestFetchAppDevTemplate_FallbackOn404(t *testing.T) { // 404 on the primary must also fall through to the official registry. pkg := "@lark-apaas/coding-template-react-standard-webapp" newFailingThenOKRegistries(t, pkg, buildTemplateTgz(t, defaultTemplateEntries()), 404) - version, _, err := fetchAppDevTemplate(context.Background(), pkg, nil) + version, _, err := fetchAppDevTemplate(context.Background(), pkg, "", nil) if err != nil || version != "1.2.3" { t.Errorf("404 fallback: version=%q err=%v", version, err) } @@ -453,7 +476,7 @@ func TestFetchAppDevTemplate_AllRegistriesFail(t *testing.T) { appDevNewTransferClient = func() *http.Client { return srv.Client() } t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) - _, _, err := fetchAppDevTemplate(context.Background(), "@lark-apaas/coding-template-x", nil) + _, _, err := fetchAppDevTemplate(context.Background(), "@lark-apaas/coding-template-x", "", nil) if err == nil { t.Fatal("all-fail must error") } @@ -509,6 +532,7 @@ func testRuntimeAppDevInitTpl(t *testing.T, appType, template, dir string) *comm cmd := &cobra.Command{Use: "+app-dev-init-template"} cmd.Flags().String("type", appType, "") cmd.Flags().String("template", template, "") + cmd.Flags().String("template-version", "", "") cmd.Flags().String("dir", dir, "") return common.TestNewRuntimeContext(cmd, nil) } diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-template.md b/skills/lark-apps/references/lark-apps-app-dev-init-template.md index ea08a36040..2a62c57312 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-init-template.md +++ b/skills/lark-apps/references/lark-apps-app-dev-init-template.md @@ -8,6 +8,7 @@ ## 命令骨架 +- 可选:`--template-version`,钉某个模板包版本或 dist-tag(如 `alpha`);缺省 latest。 - `--type` 与 `--template` 二选一:`--type frontend|full_stack` 用默认模板映射;`--template <短名>`(如 `vite-react`)直接指定模板包,优先于 `--type`——模板包名为 `@lark-apaas/coding-template-<短名>`。 - 可选:`--dir`,相对路径;**缺省就地初始化到当前目录**(须为空目录,项目名取目录名);传 `--dir ./my-app` 则创建子目录。 - 前置:已完成 `lark-cli config init`(框架级要求,纯本地命令也需要);本步骤**不需要 Node.js**。内部从 npm registry 只读下载模板包(主源 registry.npmmirror.com,失败自动降级 registry.npmjs.org 官方源) `@lark-apaas/coding-template-<模板名>` 并本地渲染,不执行任何远程脚本、不装依赖(秒级返回)。 From 0e97aa162d4cf9aff2d4485c3ba04ab8cd452624 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 18:30:55 +0800 Subject: [PATCH 26/51] feat(apps): align +app-dev-publish with the hosting protocol - build.output now points at the same-origin artifact directory itself (default dist/output); every file inside is uploaded - add optional build.output_cdn (unset = no CDN split) - missing build.command now means buildless: skip the build and pack the declared directories as-is (no npm run build default) - normalize the upload zip to the fixed output/ + output_resource/ layout regardless of project directory names - generate routes.json from the .html tree for buildless projects when absent (a project-provided routes.json is never overwritten) --- shortcuts/apps/app_dev_project_config.go | 59 +-- shortcuts/apps/app_dev_publish_zip.go | 50 ++- shortcuts/apps/apps_app_dev_publish.go | 301 +++++++++------ shortcuts/apps/apps_app_dev_publish_test.go | 354 +++++++++++++----- .../references/lark-apps-app-dev-publish.md | 16 +- 5 files changed, 518 insertions(+), 262 deletions(-) diff --git a/shortcuts/apps/app_dev_project_config.go b/shortcuts/apps/app_dev_project_config.go index 3d5d89e4ed..6d5fd20b40 100644 --- a/shortcuts/apps/app_dev_project_config.go +++ b/shortcuts/apps/app_dev_project_config.go @@ -15,22 +15,29 @@ import ( // section written back by the deploy chain. const miaodaJSONRelPath = "miaoda.json" -// Protocol defaults (§3 缺省行为): convention first, configuration override. -var ( - appDevDefaultBuildCommand = []string{"npm", "run", "build"} - appDevDefaultBuildOutput = "dist" -) +// appDevDefaultBuildOutput is the protocol default for build.output (the +// same-origin artifact directory). Since protocol v0.3 there is no default +// build command: a missing build.command means buildless (pack the output +// directory as-is). +const appDevDefaultBuildOutput = "dist/output" // appDevProjectConfig is the resolved view of the project declaration that // +app-dev-publish consumes. Fields are filled with protocol defaults when // the declaration omits them. type appDevProjectConfig struct { - Stack string - Version string + Stack string + Version string + // BuildCommand is nil for buildless projects (no build.command declared): + // the output directory is packed as-is. BuildCommand []string - BuildOutput string - AppID string - AppURL string + // BuildOutput is the same-origin artifact directory (protocol default + // dist/output). + BuildOutput string + // BuildOutputCDN is the CDN artifact directory; empty means Level 1 + // (no CDN split). + BuildOutputCDN string + AppID string + AppURL string // Source is the file the config came from: miaodaJSONRelPath or // metaRelPath (legacy fallback). It decides where the app state is // written back after a successful publish. @@ -44,8 +51,9 @@ type miaodaJSONDoc struct { Stack string `json:"stack"` Version string `json:"version"` Build struct { - Command []string `json:"command"` - Output string `json:"output"` + Command []string `json:"command"` + Output string `json:"output"` + OutputCDN string `json:"output_cdn"` } `json:"build"` App struct { ID string `json:"id"` @@ -65,13 +73,14 @@ func readAppDevProjectConfig(dir string) (cfg *appDevProjectConfig, found bool, return nil, true, appsFileIOError(jerr, "parse %s failed: %v", miaodaJSONRelPath, jerr) } cfg := &appDevProjectConfig{ - Stack: doc.Stack, - Version: doc.Version, - BuildCommand: doc.Build.Command, - BuildOutput: strings.TrimSpace(doc.Build.Output), - AppID: strings.TrimSpace(doc.App.ID), - AppURL: strings.TrimSpace(doc.App.URL), - Source: miaodaJSONRelPath, + Stack: doc.Stack, + Version: doc.Version, + BuildCommand: doc.Build.Command, + BuildOutput: strings.TrimSpace(doc.Build.Output), + BuildOutputCDN: strings.TrimSpace(doc.Build.OutputCDN), + AppID: strings.TrimSpace(doc.App.ID), + AppURL: strings.TrimSpace(doc.App.URL), + Source: miaodaJSONRelPath, } applyAppDevConfigDefaults(cfg) return cfg, true, nil @@ -95,17 +104,19 @@ func readAppDevProjectConfig(dir string) (cfg *appDevProjectConfig, found bool, return cfg, true, nil } -// applyAppDevConfigDefaults fills protocol defaults (§3): build.command → -// npm run build, build.output → dist. +// applyAppDevConfigDefaults fills protocol defaults (§3): build.output → +// dist/output. build.command deliberately has no default — missing means +// buildless (§4), and build.output_cdn stays empty for Level 1. func applyAppDevConfigDefaults(cfg *appDevProjectConfig) { - if len(cfg.BuildCommand) == 0 { - cfg.BuildCommand = append([]string(nil), appDevDefaultBuildCommand...) - } if cfg.BuildOutput == "" { cfg.BuildOutput = appDevDefaultBuildOutput } } +// Buildless reports whether the project declared no build command: packing +// uses the output directories as-is. +func (c *appDevProjectConfig) Buildless() bool { return len(c.BuildCommand) == 0 } + // writeMiaodaAppSection replaces the app state section of /miaoda.json // with {id, url} after a successful publish (§3: the app section is owned by // the deploy chain and replaced wholesale; declaration fields are never diff --git a/shortcuts/apps/app_dev_publish_zip.go b/shortcuts/apps/app_dev_publish_zip.go index c80fc963b6..21f6268ddd 100644 --- a/shortcuts/apps/app_dev_publish_zip.go +++ b/shortcuts/apps/app_dev_publish_zip.go @@ -29,35 +29,53 @@ type appDevZipball struct { FileCount int } -// buildAppDevZip packs candidates (paths relative to the dist dir, e.g. -// "output/index.html") into an in-memory zip. Entry names keep the -// output/... layout and never include the dist directory itself. -func buildAppDevZip(fio fileio.FileIO, candidates []htmlPublishCandidate) (*appDevZipball, error) { +// appDevPackEntry is one file of the normalized upload payload. ZipPath is +// the fixed protocol layout inside the zip (output/... for same-origin +// artifacts, output_resource/... for CDN artifacts) regardless of the +// project's directory names. Data comes from AbsPath, or from Content for +// CLI-generated files (a buildless routes.json). +type appDevPackEntry struct { + ZipPath string + AbsPath string + Content []byte + Size int64 +} + +// buildAppDevZip packs the normalized entries into an in-memory zip: entry +// names are the fixed output/... and output_resource/... layout the hosting +// pipeline expects. +func buildAppDevZip(fio fileio.FileIO, entries []appDevPackEntry) (*appDevZipball, error) { var rawTotal int64 - for _, c := range candidates { - rawTotal += c.Size + for _, e := range entries { + rawTotal += e.Size } if rawTotal > maxAppDevPublishRawBytes { return nil, appsValidationError( - "dist total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", + "publish payload total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxAppDevPublishRawBytes). - WithHint("reduce dist contents before publishing") + WithHint("reduce the artifact directory contents before publishing") } var buf bytes.Buffer zw := zip.NewWriter(&buf) - for _, c := range candidates { - w, err := zw.Create(c.RelPath) + for _, e := range entries { + w, err := zw.Create(e.ZipPath) if err != nil { - return nil, appsFileIOError(err, "zip create %s failed: %v", c.RelPath, err) + return nil, appsFileIOError(err, "zip create %s failed: %v", e.ZipPath, err) + } + if e.AbsPath == "" { + if _, err := w.Write(e.Content); err != nil { + return nil, appsFileIOError(err, "zip write %s failed: %v", e.ZipPath, err) + } + continue } - f, err := fio.Open(c.AbsPath) + f, err := fio.Open(e.AbsPath) if err != nil { - return nil, appsInputPathEntryError(c.AbsPath, err) + return nil, appsInputPathEntryError(e.AbsPath, err) } _, err = io.Copy(w, f) f.Close() if err != nil { - return nil, appsFileIOError(err, "zip write %s failed: %v", c.RelPath, err) + return nil, appsFileIOError(err, "zip write %s failed: %v", e.ZipPath, err) } } if err := zw.Close(); err != nil { @@ -67,7 +85,7 @@ func buildAppDevZip(fio fileio.FileIO, candidates []htmlPublishCandidate) (*appD if size > maxAppDevPublishZipBytes { return nil, appsValidationError( "packed zip size %d bytes exceeds %d bytes limit", size, maxAppDevPublishZipBytes). - WithHint("reduce dist contents; large media should be served from external storage") + WithHint("reduce the artifact directory contents; large media should be served from external storage") } - return &appDevZipball{Body: buf.Bytes(), Size: size, FileCount: len(candidates)}, nil + return &appDevZipball{Body: buf.Bytes(), Size: size, FileCount: len(entries)}, nil } diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index e31409ce67..4479485f9e 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -25,10 +25,6 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -// appDevDistDir is the fixed build output directory of the artifact-hosting -// layout; +app-dev-publish always publishes ./dist from the project root. -const appDevDistDir = "dist" - // appDevUploadURLKey is the pre_release kv carrying the presigned TOS upload // URL for the artifact-hosting chain (upload path is the server-side // convention /artifact.zip, so no separate tos_path is handed down). @@ -90,84 +86,126 @@ func ensureMetaOnlineURL(dir, onlineURL string) error { return nil } -// validateAppDevDist walks the dist directory and enforces the -// artifact-hosting layout on what the protocol consumes: output/ must hold at -// least one .html plus a valid routes.json; output_resource/ and -// output_capabilities/ ride along. Anything else at the top level is ignored -// (build tools commonly emit extra artifacts next to the protocol dirs) and -// reported via ignored for the caller to surface. Only the returned -// candidates are packed and scanned. allowSensitive skips the -// credential-file scan. -func validateAppDevDist(fio fileio.FileIO, distPath string, allowSensitive bool) (candidates []htmlPublishCandidate, ignored []string, err error) { - all, err := walkHTMLPublishCandidates(fio, distPath) +// validateAppDevOutputs walks the declared artifact directories and builds +// the normalized upload payload: every file under build.output lands at +// output/ inside the zip and every file under build.output_cdn (when +// declared) at output_resource/ — the hosting pipeline consumes this fixed +// layout and never sees the project's directory names. build.output must +// hold at least one .html; routes.json is schema-checked when present, +// generated from the .html tree for buildless projects when absent (never +// overwriting a project-provided one), and required from the build +// otherwise. generatedRoutes is the generated route count, or -1 when the +// project shipped its own routes.json. allowSensitive skips the +// credential-file scan (every listed file is uploaded, so all are scanned). +func validateAppDevOutputs(fio fileio.FileIO, cfg *appDevProjectConfig, allowSensitive bool) (entries []appDevPackEntry, generatedRoutes int, err error) { + generatedRoutes = -1 + outFiles, err := walkHTMLPublishCandidates(fio, cfg.BuildOutput) if err != nil { - // A missing dist directory means "build first", not a bad flag value. + // A missing artifact directory means "build first", not a bad flag value. if errors.Is(err, fs.ErrNotExist) { - return nil, nil, appsFailedPreconditionError( - "dist directory not found; the artifact-hosting layout expects ./dist"). - WithHint("run npm run build first, or drop --skip-build to let the command build") + hint := "run the build first, or drop --skip-build to let the command build (build.output is declared in miaoda.json)" + if cfg.Buildless() { + hint = "this project declares no build.command, so the directory is packed as-is; create it, or point miaoda.json build.output at the right directory" + } + return nil, -1, appsFailedPreconditionError( + "artifact directory %s not found (miaoda.json build.output, default dist/output)", cfg.BuildOutput). + WithHint(hint) } - return nil, nil, err + return nil, -1, err } - var hasHTML, hasRoutes, hasOutput bool - seenIgnored := map[string]bool{} - for _, c := range all { - top := c.RelPath - if i := strings.IndexByte(top, '/'); i >= 0 { - top = top[:i] - } - switch top { - case "output": - hasOutput = true - if strings.HasSuffix(c.RelPath, ".html") { - hasHTML = true - } - if c.RelPath == "output/routes.json" { - hasRoutes = true + var htmlRels []string + var sensitive []string + hasRoutes := false + for _, c := range outFiles { + if strings.HasSuffix(c.RelPath, ".html") { + htmlRels = append(htmlRels, c.RelPath) + } + if c.RelPath == "routes.json" { + hasRoutes = true + } + if !allowSensitive && isSensitiveCandidate(cfg.BuildOutput, c) { + sensitive = append(sensitive, filepath.ToSlash(filepath.Join(cfg.BuildOutput, c.RelPath))) + } + entries = append(entries, appDevPackEntry{ZipPath: "output/" + c.RelPath, AbsPath: c.AbsPath, Size: c.Size}) + } + if cfg.BuildOutputCDN != "" { + cdnFiles, err := walkHTMLPublishCandidates(fio, cfg.BuildOutputCDN) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, -1, appsFailedPreconditionError( + "CDN artifact directory %s not found (declared in miaoda.json build.output_cdn)", cfg.BuildOutputCDN). + WithHint("make the build produce it, or drop build.output_cdn to publish without the CDN split") } - candidates = append(candidates, c) - case "output_resource", "output_capabilities": - // output_resource ships to CDN; output_capabilities is the - // platform-capability placeholder — both ride along in the zip. - candidates = append(candidates, c) - default: - // Extra build artifacts next to the protocol dirs are none of the - // CLI's business — pick what the protocol needs, skip the rest. - if !seenIgnored[top] { - seenIgnored[top] = true - ignored = append(ignored, top) + return nil, -1, err + } + for _, c := range cdnFiles { + if !allowSensitive && isSensitiveCandidate(cfg.BuildOutputCDN, c) { + sensitive = append(sensitive, filepath.ToSlash(filepath.Join(cfg.BuildOutputCDN, c.RelPath))) } + entries = append(entries, appDevPackEntry{ZipPath: "output_resource/" + c.RelPath, AbsPath: c.AbsPath, Size: c.Size}) } } - sort.Strings(ignored) - if !hasOutput { - return nil, ignored, appsFailedPreconditionError( - "the build output is missing the output/ directory required by the artifact-hosting layout"). - WithHint("run the build first; expected layout: /output/{*.html,routes.json} + output_resource/") + if len(sensitive) > 0 { + return nil, -1, appDevSensitiveCandidatesError(sensitive) } - if !hasHTML { - return nil, ignored, appsFailedPreconditionError("output/ has no .html file; the protocol requires at least one (an SPA entry must be named index.html)"). - WithHint("check the build config: HTML entries belong in output/, hashed assets in output_resource/") + if len(htmlRels) == 0 { + return nil, -1, appsFailedPreconditionError( + "%s has no .html file; the protocol requires at least one (an SPA entry must be named index.html)", cfg.BuildOutput). + WithHint("check the build config: same-origin pages belong in build.output, CDN assets in build.output_cdn") } - if !hasRoutes { - return nil, ignored, appsFailedPreconditionError("output/routes.json is missing"). - WithHint("routes.json is required for content review routing; official templates generate it during the build") + switch { + case hasRoutes: + b, err := os.ReadFile(filepath.Join(cfg.BuildOutput, "routes.json")) //nolint:forbidigo // path is under the walked build output. + if err != nil { + return nil, -1, appsFileIOError(err, "read %s/routes.json failed: %v", cfg.BuildOutput, err) + } + if err := validateAppDevRoutesJSON(b); err != nil { + return nil, -1, err + } + case cfg.Buildless(): + // Buildless projects get their route enumeration scanned out of the + // .html tree by the CLI; a project-provided routes.json always wins. + b, n, err := generateAppDevRoutes(htmlRels) + if err != nil { + return nil, -1, err + } + generatedRoutes = n + entries = append(entries, appDevPackEntry{ZipPath: "output/routes.json", Content: b, Size: int64(len(b))}) + default: + return nil, -1, appsFailedPreconditionError("%s/routes.json is missing", cfg.BuildOutput). + WithHint("routes.json is required for content review routing; a declared build.command is expected to produce it (official templates generate it during the build)") } - if err := validateAppDevRoutesJSON(distPath); err != nil { - return nil, ignored, err + return entries, generatedRoutes, nil +} + +// generateAppDevRoutes derives the route enumeration from the .html file +// tree of a buildless project: any index.html maps to its directory's path +// ("/" at the root, foo/index.html to /foo) and any other page.html maps to +// /page. Entries are sorted by path for a stable payload. +func generateAppDevRoutes(htmlRels []string) (data []byte, count int, err error) { + type route struct { + Path string `json:"path"` + File string `json:"file"` } - if !allowSensitive { - var hits []string - for _, c := range candidates { - if isSensitiveCandidate(distPath, c) { - hits = append(hits, c.RelPath) - } - } - if len(hits) > 0 { - return nil, ignored, appDevSensitiveCandidatesError(hits) + seen := map[string]bool{} + routes := []route{} + for _, rel := range htmlRels { + p := "/" + strings.TrimSuffix(rel, ".html") + if strings.HasSuffix(rel, "index.html") && (rel == "index.html" || strings.HasSuffix(rel, "/index.html")) { + p = "/" + strings.TrimSuffix(strings.TrimSuffix(rel, "index.html"), "/") + } + if seen[p] { + continue } + seen[p] = true + routes = append(routes, route{Path: p, File: rel}) + } + sort.Slice(routes, func(i, j int) bool { return routes[i].Path < routes[j].Path }) + b, err := json.Marshal(routes) + if err != nil { + return nil, 0, appsFileIOError(err, "marshal generated routes.json failed: %v", err) } - return candidates, ignored, nil + return b, len(routes), nil } // appDevRoute is one entry of the routes.json route enumeration consumed by @@ -181,29 +219,25 @@ type appDevRoute struct { // appDevRoutesHint is the actionable schema reminder for routes.json errors. const appDevRoutesHint = `routes.json must be a route enumeration array, e.g. [{"path":"/","file":"index.html"}] (empty [] is allowed for a static site); it feeds security scanning, so it must match the real routes` -// validateAppDevRoutesJSON light-checks output/routes.json against the +// validateAppDevRoutesJSON light-checks a routes.json payload against the // route-enumeration schema so problems fail at publish time instead of // bouncing off the TNS scan later: top level must be an array, every entry // needs a /-prefixed path, and paths must be unique. -func validateAppDevRoutesJSON(distPath string) error { - b, err := os.ReadFile(filepath.Join(distPath, "output", "routes.json")) //nolint:forbidigo // path is under the walked build output. - if err != nil { - return appsFileIOError(err, "read output/routes.json failed: %v", err) - } +func validateAppDevRoutesJSON(b []byte) error { var routes []appDevRoute if err := json.Unmarshal(b, &routes); err != nil { - return appsFailedPreconditionError("output/routes.json is not a valid route enumeration array: %v", err). + return appsFailedPreconditionError("routes.json is not a valid route enumeration array: %v", err). WithHint(appDevRoutesHint) } seen := make(map[string]bool, len(routes)) for i, r := range routes { path := strings.TrimSpace(r.Path) if path == "" || !strings.HasPrefix(path, "/") { - return appsFailedPreconditionError("output/routes.json entry %d has an invalid path %q (required, must start with /, no base prefix)", i, r.Path). + return appsFailedPreconditionError("routes.json entry %d has an invalid path %q (required, must start with /, no base prefix)", i, r.Path). WithHint(appDevRoutesHint) } if seen[path] { - return appsFailedPreconditionError("output/routes.json has duplicate path %q (paths must be unique)", path). + return appsFailedPreconditionError("routes.json has duplicate path %q (paths must be unique)", path). WithHint(appDevRoutesHint) } seen[path] = true @@ -212,13 +246,14 @@ func validateAppDevRoutesJSON(distPath string) error { } // appDevSensitiveCandidatesError mirrors sensitiveCandidatesError with -// publish-specific wording: this command has no --path flag and the payload -// is always ./dist, so the html-publish message would misdirect the user. +// publish-specific wording: this command has no --path flag — the payload is +// the declared artifact directories — so the html-publish message would +// misdirect the user. func appDevSensitiveCandidatesError(hits []string) error { return appsValidationError( - "dist contains %d credential file(s) that should not be published: %s", + "the publish payload contains %d credential file(s) that should not be published: %s", len(hits), truncatedJoin(hits, maxSensitiveListInError)). - WithHint("remove these files from the build output, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)") + WithHint("remove these files from the artifact directories, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)") } // resolveAppDevPublishTarget loads the project declaration (miaoda.json @@ -298,23 +333,23 @@ var appDevRunner envCommandRunner = execEnvCommandRunner{} var appDevNewTransferClient = newFileTransferClient // AppsAppDevPublish builds and publishes a local web app project to its -// Miaoda app. Run from the project root containing .spark/meta.json. +// Miaoda app. Run from the project root containing miaoda.json. var AppsAppDevPublish = common.Shortcut{ Service: appsService, Command: "+app-dev-publish", - Description: "Build and publish a local web app project to its Miaoda app (run from the project root containing .spark/meta.json)", + Description: "Build and publish a local web app project to its Miaoda app (run from the project root containing miaoda.json)", Risk: "write", Tips: []string{ "Example: lark-cli apps +app-dev-publish (run from the project root)", - "Example: lark-cli apps +app-dev-publish --skip-build (reuse an existing ./dist)", - "Prerequisite: .spark/meta.json must contain app_id (create the app with +create first)", + "Example: lark-cli apps +app-dev-publish --skip-build (reuse the existing build.output directory)", + "Prerequisite: an app id in miaoda.json or via --app-id (create the app with +create first)", }, Scopes: []string{"spark:app:write", "spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "publish target app ID (app_ prefix); optional when miaoda.json already records one — on a successful publish it is saved back into miaoda.json, and a value conflicting with the recorded one is rejected"}, - {Name: "skip-build", Type: "bool", Desc: "skip the build.command declared in miaoda.json (default npm run build) and publish the existing build.output directory as-is"}, + {Name: "skip-build", Type: "bool", Desc: "skip the build.command declared in miaoda.json and publish the existing build.output directory as-is (no effect on buildless projects, which never build)"}, {Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / etc. in the publish payload)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -324,37 +359,44 @@ var AppsAppDevPublish = common.Shortcut{ } // Sensitive-file scan lives in Validate so that --dry-run exits // non-zero on a hit — the one deliberate exception to dry-run's - // exit-0 convention (mirrors +html-publish). Walk errors (e.g. dist - // missing) are not fatal here; DryRun/Execute surface them with - // richer context. + // exit-0 convention (mirrors +html-publish). Every file under the + // declared artifact directories is uploaded, so all are scanned. + // Walk errors (e.g. directory missing) are not fatal here; + // DryRun/Execute surface them with richer context. if !rctx.Bool("allow-sensitive") { - if candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), cfg.BuildOutput); err == nil { - var hits []string - for _, c := range candidates { - top := c.RelPath - if i := strings.IndexByte(top, '/'); i >= 0 { - top = top[:i] - } - if top != "output" && top != "output_resource" && top != "output_capabilities" { - continue // not uploaded, not scanned - } - if isSensitiveCandidate(cfg.BuildOutput, c) { - hits = append(hits, c.RelPath) - } + var hits []string + for _, dir := range []string{cfg.BuildOutput, cfg.BuildOutputCDN} { + if dir == "" { + continue } - if len(hits) > 0 { - return appDevSensitiveCandidatesError(hits) + if candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), dir); err == nil { + for _, c := range candidates { + if isSensitiveCandidate(dir, c) { + hits = append(hits, filepath.ToSlash(filepath.Join(dir, c.RelPath))) + } + } } } + if len(hits) > 0 { + return appDevSensitiveCandidatesError(hits) + } } - if rctx.Bool("skip-build") { + switch { + case cfg.Buildless(): if _, err := rctx.FileIO().Stat(cfg.BuildOutput); err != nil { - return appsFailedPreconditionError("--skip-build is set but the build output directory %s does not exist", cfg.BuildOutput). + return appsFailedPreconditionError("artifact directory %s does not exist (miaoda.json build.output, default dist/output)", cfg.BuildOutput). + WithHint("this project declares no build.command, so the directory is packed as-is; create it, or declare build.command in miaoda.json") + } + case rctx.Bool("skip-build"): + if _, err := rctx.FileIO().Stat(cfg.BuildOutput); err != nil { + return appsFailedPreconditionError("--skip-build is set but the artifact directory %s does not exist", cfg.BuildOutput). WithHint("run the build first, or drop --skip-build to let the command build") } - } else if _, err := appDevLookPath(cfg.BuildCommand[0]); err != nil { - return appsFailedPreconditionError("build command executable %q not found on PATH", cfg.BuildCommand[0]). - WithHint("install it (default build.command is npm run build, provided by Node.js), or build manually and retry with --skip-build") + default: + if _, err := appDevLookPath(cfg.BuildCommand[0]); err != nil { + return appsFailedPreconditionError("build command executable %q not found on PATH", cfg.BuildCommand[0]). + WithHint("install it (build.command is declared in miaoda.json), or build manually and retry with --skip-build") + } } return nil }, @@ -381,16 +423,23 @@ var AppsAppDevPublish = common.Shortcut{ POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))). Body(map[string]string{}) } - dry.Set("build_command", strings.Join(cfg.BuildCommand, " ")+" (from miaoda.json build.command, default npm run build; env allowlist: MIAODA_* keys from pre_release; skipped with --skip-build)") - dry.Set("build_output", cfg.BuildOutput) - if candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), cfg.BuildOutput); err != nil { - dry.Set("dist_state", "missing or unreadable: "+err.Error()) + if cfg.Buildless() { + dry.Set("build_command", "(buildless: miaoda.json declares no build.command; the artifact directories are packed as-is)") + } else { + dry.Set("build_command", strings.Join(cfg.BuildCommand, " ")+" (from miaoda.json build.command; env allowlist: MIAODA_* keys from pre_release; skipped with --skip-build)") + } + dry.Set("build_output", cfg.BuildOutput+" -> zip output/ (same-origin artifacts)") + if cfg.BuildOutputCDN != "" { + dry.Set("build_output_cdn", cfg.BuildOutputCDN+" -> zip output_resource/ (CDN artifacts)") + } else { + dry.Set("build_output_cdn", "(not declared: no CDN split, all assets served same-origin)") + } + if entries, gen, verr := validateAppDevOutputs(rctx.FileIO(), cfg, rctx.Bool("allow-sensitive")); verr != nil { + dry.Set("output_validation_error", verr.Error()) } else { - dry.Set("dist_file_count", len(candidates)) - if _, dryIgnored, verr := validateAppDevDist(rctx.FileIO(), cfg.BuildOutput, rctx.Bool("allow-sensitive")); verr != nil { - dry.Set("dist_validation_error", verr.Error()) - } else if len(dryIgnored) > 0 { - dry.Set("dist_ignored_entries", strings.Join(dryIgnored, ", ")) + dry.Set("upload_file_count", len(entries)) + if gen >= 0 { + dry.Set("routes_json", fmt.Sprintf("absent; will be generated from the .html tree (%d route(s))", gen)) } } return dry @@ -426,7 +475,12 @@ var AppsAppDevPublish = common.Shortcut{ } built := false - if !rctx.Bool("skip-build") { + switch { + case cfg.Buildless(): + fmt.Fprintf(rctx.IO().ErrOut, "no build.command declared; packing %s as-is (buildless)\n", cfg.BuildOutput) + case rctx.Bool("skip-build"): + // The user built already; publish the existing artifacts. + default: env, keys := appDevBuildEnv(kvm) if len(keys) > 0 { fmt.Fprintf(rctx.IO().ErrOut, "injecting build env: %s\n", strings.Join(keys, ", ")) @@ -440,15 +494,14 @@ var AppsAppDevPublish = common.Shortcut{ built = true } - candidates, ignoredEntries, err := validateAppDevDist(rctx.FileIO(), cfg.BuildOutput, rctx.Bool("allow-sensitive")) + entries, generatedRoutes, err := validateAppDevOutputs(rctx.FileIO(), cfg, rctx.Bool("allow-sensitive")) if err != nil { return err } - if len(ignoredEntries) > 0 { - fmt.Fprintf(rctx.IO().ErrOut, "skipping %d top-level entr(ies) outside the protocol layout: %s\n", - len(ignoredEntries), strings.Join(ignoredEntries, ", ")) + if generatedRoutes >= 0 { + fmt.Fprintf(rctx.IO().ErrOut, "routes.json not found; generated %d route(s) from the .html tree\n", generatedRoutes) } - zipball, err := buildAppDevZip(rctx.FileIO(), candidates) + zipball, err := buildAppDevZip(rctx.FileIO(), entries) if err != nil { return err } diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index 35b36042a0..f941a56f4d 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -75,11 +75,22 @@ func TestEnsureMetaOnlineURL(t *testing.T) { } } -// --- dist layout validation --- +// --- artifact layout validation --- + +// testAppDevCfg builds a resolved project config for validation tests. +// buildless mirrors a miaoda.json without build.command. +func testAppDevCfg(output, cdn string, buildless bool) *appDevProjectConfig { + cfg := &appDevProjectConfig{BuildOutput: output, BuildOutputCDN: cdn} + if !buildless { + cfg.BuildCommand = []string{"npm", "run", "build"} + } + return cfg +} // writeDistFiles creates files (relative to base) with parent dirs. A file -// named routes.json gets valid v1 schema content so protocol validation -// passes by default; tests that need a broken one overwrite it afterwards. +// named routes.json gets valid route-enumeration content so protocol +// validation passes by default; tests that need a broken one overwrite it +// afterwards. func writeDistFiles(t *testing.T, base string, files []string) { t.Helper() for _, f := range files { @@ -97,75 +108,121 @@ func writeDistFiles(t *testing.T, base string, files []string) { } } -func TestValidateAppDevDist(t *testing.T) { +func TestValidateAppDevOutputs(t *testing.T) { tests := []struct { - name string - files []string - wantErr string // "" = valid + name string + files []string + buildless bool + wantErr string // "" = valid }{ - {"ok full", []string{"output/index.html", "output/routes.json", "output_resource/index.js"}, ""}, - {"ok no resource", []string{"output/index.html", "output/routes.json"}, ""}, - {"ok non-index html", []string{"output/page.html", "output/routes.json"}, ""}, - {"ok capabilities dir", []string{"output/index.html", "output/routes.json", "output_capabilities/cap.json"}, ""}, - {"ok extra top-level dir ignored", []string{"output/index.html", "output/routes.json", "client/x.js"}, ""}, - {"ok extra top-level file ignored", []string{"output/index.html", "output/routes.json", "notes.md"}, ""}, - {"only strays, no output dir", []string{"stray.txt"}, "missing the output/ directory"}, - {"no html", []string{"output/routes.json"}, "no .html file"}, - {"no routes", []string{"output/index.html"}, "routes.json"}, + {"ok minimal", []string{"index.html", "routes.json"}, false, ""}, + {"ok non-index html", []string{"page.html", "routes.json"}, false, ""}, + {"ok extra files ride along", []string{"index.html", "routes.json", "assets/logo.png", "manifest.json"}, false, ""}, + {"ok buildless with routes", []string{"index.html", "routes.json"}, true, ""}, + {"ok buildless generates routes", []string{"index.html"}, true, ""}, + {"no html", []string{"routes.json"}, false, "no .html file"}, + {"no routes with build command", []string{"index.html"}, false, "routes.json is missing"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - dist := filepath.Join(t.TempDir(), "dist") - writeDistFiles(t, dist, tt.files) - _, _, err := validateAppDevDist(permissiveFIO{}, dist, false) - if tt.wantErr == "" { - if err != nil { - t.Errorf("want valid, got %v", err) + out := filepath.Join(t.TempDir(), "dist", "output") + writeDistFiles(t, out, tt.files) + entries, gen, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", tt.buildless), false) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("err = %v, want containing %q", err, tt.wantErr) } return } - if err == nil || !strings.Contains(err.Error(), tt.wantErr) { - t.Errorf("err = %v, want containing %q", err, tt.wantErr) + if err != nil { + t.Fatalf("want valid, got %v", err) + } + // Everything in the artifact directory is uploaded, normalized + // under the fixed zip prefix. + hasRoutes := false + for _, e := range entries { + if !strings.HasPrefix(e.ZipPath, "output/") { + t.Errorf("zip path %q must be normalized under output/", e.ZipPath) + } + if e.ZipPath == "output/routes.json" { + hasRoutes = true + } + } + if len(entries) < len(tt.files) { + t.Errorf("entries = %d, want at least %d (all files upload)", len(entries), len(tt.files)) + } + if !hasRoutes { + t.Error("payload must always carry output/routes.json (shipped or generated)") + } + wantGen := tt.buildless && !strings.Contains(strings.Join(tt.files, " "), "routes.json") + if (gen >= 0) != wantGen { + t.Errorf("generatedRoutes = %d, wantGenerated=%v", gen, wantGen) } }) } } -func TestValidateAppDevDist_IgnoresExtrasAndExcludesFromPack(t *testing.T) { - dist := filepath.Join(t.TempDir(), "dist") - writeDistFiles(t, dist, []string{ - "output/index.html", "output/routes.json", - "client/bundle.js", "server/main.js", "stats.json", - }) - candidates, ignored, err := validateAppDevDist(permissiveFIO{}, dist, false) +func TestValidateAppDevOutputs_CDNSplit(t *testing.T) { + root := t.TempDir() + out := filepath.Join(root, "dist", "output") + cdn := filepath.Join(root, "dist", "output_resource") + writeDistFiles(t, out, []string{"index.html", "routes.json"}) + writeDistFiles(t, cdn, []string{"static/a.js"}) + entries, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, cdn, false), false) if err != nil { t.Fatal(err) } - if len(ignored) != 3 { - t.Errorf("ignored = %v, want client/server/stats.json tops", ignored) + got := map[string]bool{} + for _, e := range entries { + got[e.ZipPath] = true } - for _, c := range candidates { - if !strings.HasPrefix(c.RelPath, "output/") { - t.Errorf("candidate outside protocol dirs must not be packed: %s", c.RelPath) + for _, want := range []string{"output/index.html", "output/routes.json", "output_resource/static/a.js"} { + if !got[want] { + t.Errorf("missing normalized entry %q in %v", want, got) } } - // Sensitive files in ignored dirs are not shipped, hence not scanned. - writeDistFiles(t, dist, []string{"client/.env"}) - if _, _, err := validateAppDevDist(permissiveFIO{}, dist, false); err != nil { - t.Errorf("sensitive file in an ignored dir must not block: %v", err) + // A declared but missing CDN directory is a hard error, not silence. + _, _, err = validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, filepath.Join(root, "nope"), false), false) + if err == nil || !strings.Contains(err.Error(), "CDN artifact directory") { + t.Errorf("missing declared cdn dir must fail, got %v", err) } } -func TestValidateAppDevDist_RoutesSchema(t *testing.T) { - dist := filepath.Join(t.TempDir(), "dist") - writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json"}) +func TestGenerateAppDevRoutes(t *testing.T) { + b, n, err := generateAppDevRoutes([]string{"index.html", "foo/index.html", "bar.html", "dup.html", "dup/index.html"}) + if err != nil { + t.Fatal(err) + } + if n != 4 { + t.Errorf("count = %d, want 4 (dup path deduped)", n) + } + var routes []map[string]string + if err := json.Unmarshal(b, &routes); err != nil { + t.Fatalf("generated routes.json not valid JSON: %v", err) + } + got := map[string]string{} + for _, r := range routes { + got[r["path"]] = r["file"] + } + if got["/"] != "index.html" || got["/foo"] != "foo/index.html" || got["/bar"] != "bar.html" { + t.Errorf("routes = %v", got) + } + // The generated payload must pass the same schema check shipped files do. + if err := validateAppDevRoutesJSON(b); err != nil { + t.Errorf("generated routes.json fails schema: %v", err) + } +} + +func TestValidateAppDevOutputs_RoutesSchema(t *testing.T) { + out := filepath.Join(t.TempDir(), "dist", "output") + writeDistFiles(t, out, []string{"index.html", "routes.json"}) set := func(body string) { - os.WriteFile(filepath.Join(dist, "output", "routes.json"), []byte(body), 0o644) + os.WriteFile(filepath.Join(out, "routes.json"), []byte(body), 0o644) } check := func(body, wantErr string) { t.Helper() set(body) - _, _, err := validateAppDevDist(permissiveFIO{}, dist, false) + _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false), false) if wantErr == "" { if err != nil { t.Errorf("routes %q should be valid: %v", body, err) @@ -187,8 +244,9 @@ func TestValidateAppDevDist_RoutesSchema(t *testing.T) { check(`[{"path":"/","file":"index.html","name":"首页","future":1}]`, "") // 未识别字段忽略 } -func TestValidateAppDevDist_Missing(t *testing.T) { - _, _, err := validateAppDevDist(permissiveFIO{}, filepath.Join(t.TempDir(), "dist"), false) +func TestValidateAppDevOutputs_Missing(t *testing.T) { + missing := filepath.Join(t.TempDir(), "dist", "output") + _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(missing, "", false), false) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition { t.Errorf("subtype = %q, want failed_precondition", p.Subtype) @@ -196,16 +254,22 @@ func TestValidateAppDevDist_Missing(t *testing.T) { if !strings.Contains(p.Hint, "--skip-build") { t.Errorf("hint = %q", p.Hint) } + // Buildless projects get buildless-specific guidance, not a build hint. + _, _, err = validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(missing, "", true), false) + p = requireAppsProblem(t, err, errs.CategoryValidation) + if !strings.Contains(p.Hint, "no build.command") { + t.Errorf("buildless hint = %q", p.Hint) + } } -func TestValidateAppDevDist_Sensitive(t *testing.T) { - dist := filepath.Join(t.TempDir(), "dist") - writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json", "output/.env"}) - _, _, err := validateAppDevDist(permissiveFIO{}, dist, false) +func TestValidateAppDevOutputs_Sensitive(t *testing.T) { + out := filepath.Join(t.TempDir(), "dist", "output") + writeDistFiles(t, out, []string{"index.html", "routes.json", ".env"}) + _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false), false) if err == nil || !strings.Contains(err.Error(), "credential file") { t.Errorf("sensitive file must be rejected, got %v", err) } - if _, _, err := validateAppDevDist(permissiveFIO{}, dist, true); err != nil { + if _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false), true); err != nil { t.Errorf("allow-sensitive must waive the scan: %v", err) } } @@ -213,13 +277,15 @@ func TestValidateAppDevDist_Sensitive(t *testing.T) { // --- zip packing --- func TestBuildAppDevZip(t *testing.T) { - dist := filepath.Join(t.TempDir(), "dist") - writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json", "output_resource/a.js"}) - candidates, _, err := validateAppDevDist(permissiveFIO{}, dist, false) + root := t.TempDir() + out, cdn := filepath.Join(root, "dist", "output"), filepath.Join(root, "dist", "output_resource") + writeDistFiles(t, out, []string{"index.html", "routes.json"}) + writeDistFiles(t, cdn, []string{"a.js"}) + entries, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, cdn, false), false) if err != nil { t.Fatal(err) } - zipball, err := buildAppDevZip(permissiveFIO{}, candidates) + zipball, err := buildAppDevZip(permissiveFIO{}, entries) if err != nil { t.Fatal(err) } @@ -233,22 +299,48 @@ func TestBuildAppDevZip(t *testing.T) { } for _, n := range names { if !want[n] { - t.Errorf("unexpected zip entry %q (dist prefix must be stripped)", n) + t.Errorf("unexpected zip entry %q (project dir names must be normalized away)", n) } } } +func TestBuildAppDevZip_InlineGeneratedRoutes(t *testing.T) { + out := filepath.Join(t.TempDir(), "dist", "output") + writeDistFiles(t, out, []string{"index.html"}) + entries, gen, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", true), false) + if err != nil { + t.Fatal(err) + } + if gen != 1 { + t.Fatalf("generatedRoutes = %d, want 1", gen) + } + zipball, err := buildAppDevZip(permissiveFIO{}, entries) + if err != nil { + t.Fatal(err) + } + names := zipEntryNames(t, zipball.Body) + found := false + for _, n := range names { + if n == "output/routes.json" { + found = true + } + } + if !found { + t.Errorf("generated routes.json missing from zip: %v", names) + } +} + func TestBuildAppDevZip_RawSizeCap(t *testing.T) { orig := maxAppDevPublishRawBytes maxAppDevPublishRawBytes = 1 t.Cleanup(func() { maxAppDevPublishRawBytes = orig }) - dist := filepath.Join(t.TempDir(), "dist") - writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json"}) - candidates, _, err := validateAppDevDist(permissiveFIO{}, dist, false) + out := filepath.Join(t.TempDir(), "dist", "output") + writeDistFiles(t, out, []string{"index.html", "routes.json"}) + entries, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false), false) if err != nil { t.Fatal(err) } - if _, err := buildAppDevZip(permissiveFIO{}, candidates); err == nil || !strings.Contains(err.Error(), "exceeds") { + if _, err := buildAppDevZip(permissiveFIO{}, entries); err == nil || !strings.Contains(err.Error(), "exceeds") { t.Errorf("raw cap must reject, got %v", err) } } @@ -257,18 +349,18 @@ func TestBuildAppDevZip_ZipSizeCap(t *testing.T) { orig := maxAppDevPublishZipBytes maxAppDevPublishZipBytes = 1 t.Cleanup(func() { maxAppDevPublishZipBytes = orig }) - dist := filepath.Join(t.TempDir(), "dist") - writeDistFiles(t, dist, []string{"output/index.html", "output/routes.json"}) - candidates, _, err := validateAppDevDist(permissiveFIO{}, dist, false) + out := filepath.Join(t.TempDir(), "dist", "output") + writeDistFiles(t, out, []string{"index.html", "routes.json"}) + entries, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false), false) if err != nil { t.Fatal(err) } - _, err = buildAppDevZip(permissiveFIO{}, candidates) + _, err = buildAppDevZip(permissiveFIO{}, entries) if err == nil || !strings.Contains(err.Error(), "packed zip size") { t.Errorf("zip cap must reject, got %v", err) } p, _ := errs.ProblemOf(err) - if p == nil || !strings.Contains(p.Hint, "reduce dist contents") { + if p == nil || !strings.Contains(p.Hint, "reduce the artifact directory contents") { t.Errorf("hint = %v", p) } } @@ -434,7 +526,7 @@ func TestAppDevPublishValidate_AppIDMismatch(t *testing.T) { func TestAppDevPublishExecute_FlagAppIDBackfill(t *testing.T) { root := chdirProjectRoot(t, `{"stack":"react-standard-webapp"}`) // no app_id - writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_flag1", srv.URL, nil) @@ -454,7 +546,7 @@ func TestAppDevPublishExecute_FlagAppIDBackfill(t *testing.T) { func TestAppDevPublishExecute_FlagMatchesMeta(t *testing.T) { root := chdirProjectRoot(t, `{"app_id":"app_x"}`) - writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", srv.URL, nil) @@ -484,15 +576,15 @@ func TestAppDevPublishValidate_BadAppID(t *testing.T) { func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { root := chdirProjectRoot(t, `{"app_id":"app_x"}`) - writeDistFiles(t, filepath.Join(root, appDevDistDir), - []string{"output/index.html", "output/routes.json", "output_resource/.env"}) + writeDistFiles(t, filepath.Join(root, "dist"), + []string{"output/index.html", "output/routes.json", "output/.env"}) factory, stdout, _ := newAppsExecuteFactory(t) // Sensitive hits are the one exception to dry-run's exit-0 convention: // Validate rejects before the DryRun branch runs. err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) - if !strings.Contains(p.Message, "dist contains") || !strings.Contains(p.Message, "credential file") { + if !strings.Contains(p.Message, "publish payload contains") || !strings.Contains(p.Message, "credential file") { t.Errorf("message = %q", p.Message) } // This command has no --path flag; the error must not mention one. @@ -507,17 +599,36 @@ func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { } func TestAppDevPublishValidate_SkipBuildNoDist(t *testing.T) { - chdirProjectRoot(t, `{"app_id":"app_x"}`) + chdirMiaodaProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) - if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "build output directory dist does not exist") { + if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "--skip-build is set but the artifact directory dist/output does not exist") { t.Errorf("got %v", p) } } +func TestAppDevPublishValidate_BuildlessNoDist(t *testing.T) { + // No build.command declared (legacy .spark fallback resolves to buildless): + // the artifact directory must already exist. + chdirProjectRoot(t, `{"app_id":"app_x"}`) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "artifact directory dist/output does not exist") { + t.Errorf("got %v", p) + } + if !strings.Contains(p.Hint, "no build.command") { + t.Errorf("hint = %q", p.Hint) + } +} + func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { - root := chdirProjectRoot(t, `{"app_id":"app_x","stack":"react-standard-webapp"}`) + root := chdirMiaodaProjectRoot(t, `{ + "stack": "react-standard-webapp", + "build": { "command": ["npm", "run", "build"], "output": "dist/output" }, + "app": { "id": "app_x" } +}`) var uploaded []byte var contentType string srv := newTOSTLSServer(t, func(w http.ResponseWriter, r *http.Request) { @@ -528,7 +639,7 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { w.WriteHeader(200) }) f := &fakeEnvRunner{sideEffect: func() { - writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json", "output_resource/a.js"}) + writeDistFiles(t, filepath.Join(root, "dist", "output"), []string{"index.html", "routes.json", "a.js"}) }} withFakeEnvRunner(t, f) factory, stdout, reg := newAppsExecuteFactory(t) @@ -568,18 +679,51 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { if _, hasPoll := data["poll_hint"]; hasPoll { t.Error("sync success must not carry poll_hint") } - // meta.json backfill. + // miaoda.json app-section writeback. + b, _ := os.ReadFile(filepath.Join(root, miaodaJSONRelPath)) + var doc map[string]interface{} + _ = json.Unmarshal(b, &doc) + app, _ := doc["app"].(map[string]interface{}) + if app == nil || app["id"] != "app_x" || app["url"] != "https://x.feishuapp.cn/app/app_x" { + t.Errorf("app section after publish = %v", doc["app"]) + } +} + +func TestAppDevPublishExecute_BuildlessSparkSync(t *testing.T) { + // Legacy .spark project without a declaration: buildless per protocol — + // no build runs, dist/output is packed as-is, online_url is backfilled. + root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) + f := &fakeEnvRunner{} + withFakeEnvRunner(t, f) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + stubReleases(reg, "app_x", map[string]interface{}{ + "release_id": "rel_30", "status": "finished", + "online_url": "https://x/app/app_x", + }) + if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + if f.called { + t.Error("buildless project must never invoke a build command") + } + data := parseEnvelopeData(t, stdout) + if data["built"] != false { + t.Errorf("built = %v, want false for buildless", data["built"]) + } b, _ := os.ReadFile(filepath.Join(root, metaRelPath)) var meta map[string]interface{} _ = json.Unmarshal(b, &meta) - if meta["online_url"] != "https://x.feishuapp.cn/app/app_x" || meta["app_id"] != "app_x" { + if meta["online_url"] != "https://x/app/app_x" { t.Errorf("meta after publish = %v", meta) } } func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { root := chdirProjectRoot(t, `{"app_id":"app_x"}`) - writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", srv.URL, nil) @@ -606,7 +750,7 @@ func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { } func TestAppDevPublishExecute_BuildFails(t *testing.T) { - chdirProjectRoot(t, `{"app_id":"app_x"}`) + chdirMiaodaProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) f := &fakeEnvRunner{stderr: "TS2304: boom", err: errors.New("exit 1")} withFakeEnvRunner(t, f) @@ -624,7 +768,7 @@ func TestAppDevPublishExecute_BuildFails(t *testing.T) { func TestAppDevPublishExecute_PreReleaseMissingKVs(t *testing.T) { root := chdirProjectRoot(t, `{"app_id":"app_x"}`) - writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "GET", @@ -643,7 +787,7 @@ func TestAppDevPublishExecute_PreReleaseMissingKVs(t *testing.T) { func TestAppDevPublishExecute_NonHTTPSUploadURL(t *testing.T) { root := chdirProjectRoot(t, `{"app_id":"app_x"}`) - writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", "http://insecure.example/put", nil) err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) @@ -655,7 +799,7 @@ func TestAppDevPublishExecute_NonHTTPSUploadURL(t *testing.T) { func TestAppDevPublishExecute_TOS5xx(t *testing.T) { root := chdirProjectRoot(t, `{"app_id":"app_x"}`) - writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html", "output/routes.json"}) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", srv.URL, nil) @@ -667,8 +811,8 @@ func TestAppDevPublishExecute_TOS5xx(t *testing.T) { } func TestAppDevPublishDryRun(t *testing.T) { - root := chdirProjectRoot(t, `{"app_id":"app_x"}`) - writeDistFiles(t, filepath.Join(root, appDevDistDir), []string{"output/index.html"}) + root := chdirMiaodaProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"],"output":"dist/output"}}`) + writeDistFiles(t, filepath.Join(root, "dist", "output"), []string{"index.html"}) factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) @@ -680,8 +824,10 @@ func TestAppDevPublishDryRun(t *testing.T) { if data["app_id"] != "app_x" { t.Errorf("app_id = %v", data["app_id"]) } - if verr, _ := data["dist_validation_error"].(string); !strings.Contains(verr, "routes.json") { - t.Errorf("dist_validation_error = %v (routes.json missing should surface)", data["dist_validation_error"]) + // A declared build.command is expected to produce routes.json — its + // absence surfaces as a validation error (never CLI-generated here). + if verr, _ := data["output_validation_error"].(string); !strings.Contains(verr, "routes.json") { + t.Errorf("output_validation_error = %v (routes.json missing should surface)", data["output_validation_error"]) } buildCmd, _ := data["build_command"].(string) if !strings.Contains(buildCmd, "MIAODA_*") { @@ -689,6 +835,35 @@ func TestAppDevPublishDryRun(t *testing.T) { } } +func TestAppDevPublishDryRun_Buildless(t *testing.T) { + root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html"}) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + data, err := decodeDryRunDataMap(stdout.Bytes()) + if err != nil { + t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String()) + } + buildCmd, _ := data["build_command"].(string) + if !strings.Contains(buildCmd, "buildless") { + t.Errorf("build_command = %q, want buildless note", buildCmd) + } + // Missing routes.json is fine for buildless — the CLI generates it. + if verr, has := data["output_validation_error"]; has { + t.Errorf("output_validation_error = %v, want none", verr) + } + routes, _ := data["routes_json"].(string) + if !strings.Contains(routes, "generated") { + t.Errorf("routes_json = %q, want generation note", routes) + } + cdn, _ := data["build_output_cdn"].(string) + if !strings.Contains(cdn, "not declared") { + t.Errorf("build_output_cdn = %q", cdn) + } +} + func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { // miaoda.json declares a custom build command and output dir; the app // section is replaced wholesale on success. @@ -698,10 +873,9 @@ func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { "app": { "id": "app_x" } }`) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + // build.output points straight at the same-origin artifact directory. f := &fakeEnvRunner{sideEffect: func() { - writeDistFiles(t, filepath.Join(root, "public"), []string{"output/index.html", "output/routes.json"}) - routes := `[{"path":"/","file":"index.html"}]` - os.WriteFile(filepath.Join(root, "public", "output", "routes.json"), []byte(routes), 0o644) + writeDistFiles(t, filepath.Join(root, "public"), []string{"index.html", "routes.json"}) }} withFakeEnvRunner(t, f) factory, stdout, reg := newAppsExecuteFactory(t) @@ -734,7 +908,7 @@ func TestAppDevPublishExecute_MiaodaFlagBackfill(t *testing.T) { // No recorded app id in miaoda.json: --app-id publishes and the app // section is written on success (async: no url yet). root := chdirMiaodaProjectRoot(t, `{"stack":"react-standard-webapp"}`) - writeDistFiles(t, filepath.Join(root, appDevDefaultBuildOutput), []string{"output/index.html", "output/routes.json"}) + writeDistFiles(t, filepath.Join(root, appDevDefaultBuildOutput), []string{"index.html", "routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_new1", srv.URL, nil) diff --git a/skills/lark-apps/references/lark-apps-app-dev-publish.md b/skills/lark-apps/references/lark-apps-app-dev-publish.md index db3aa84444..cd10efd076 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-publish.md +++ b/skills/lark-apps/references/lark-apps-app-dev-publish.md @@ -8,17 +8,17 @@ ## 命令骨架 -- **必须在项目根目录执行**(项目根须有 `miaoda.json`;旧项目回退读 `.spark/meta.json`)。产物目录取 miaoda.json 的 `build.output`(缺省 `dist`),无 `--path` 参数。 +- **必须在项目根目录执行**(项目根须有 `miaoda.json`;旧项目回退读 `.spark/meta.json`)。同源产物目录取 miaoda.json 的 `build.output`(缺省 `dist/output`),CDN 产物目录取可选的 `build.output_cdn`(不声明 = 无 CDN 分离),无 `--path` 参数。 - `--app-id` 可选:首次发布传它指定目标(成功后自动写入 `miaoda.json` 的 app 段,后续免传);已记录 app id 时可省略;**两者都有且不一致会被拒绝**(防误发错目标),确要切换先更新 miaoda.json。 -- 可选:`--skip-build`(跳过 `npm run build`,直接发布已有 `./dist`)、`--allow-sensitive`(跳过凭据文件扫描)。 -- 内部流程:读 miaoda.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → 执行 `build.command`(缺省 `npm run build`,argv 直接执行不走 shell,自动注入变量)→ 校验产物协议 → zip 上传 → 触发发布。 -- 产物协议(详见《妙搭产物托管协议规范》):`output/` 必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(**路由枚举数组**,如 `[{"path":"/","file":"index.html"}]`,纯静态站可为空数组;它是安全扫描的输入,必须与真实路由一致);`output_resource/`、`output_capabilities/` 可选;顶层其他条目**自动忽略不上传**(stderr 会列出跳过项)。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 +- 可选:`--skip-build`(跳过 `build.command`,直接发布已有产物目录)、`--allow-sensitive`(跳过凭据文件扫描)。 +- 内部流程:读 miaoda.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → 执行 `build.command`(argv 直接执行不走 shell,自动注入变量;**miaoda.json 未声明 build.command = buildless,跳过构建直接打包**)→ 校验产物协议 → 归一化打包(`build.output` → zip 内 `output/`,`build.output_cdn` → zip 内 `output_resource/`,流水线不感知项目目录名)→ 上传 → 触发发布。 +- 产物协议(详见《妙搭产物托管协议规范》):`build.output` 目录必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(**路由枚举数组**,如 `[{"path":"/","file":"index.html"}]`,纯静态站可为空数组;它是安全扫描的输入,必须与真实路由一致);目录内其余静态文件全部随包上传。**buildless 项目缺 routes.json 时由 CLI 扫描 `.html` 文件树自动生成**(`foo/index.html` → `/foo`),工程自带的 routes.json 永不被覆盖。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 ## 示例 ```bash -lark-cli apps +app-dev-publish --app-id app_xxx # 首次发布:指定目标,成功后写入 meta.json -lark-cli apps +app-dev-publish # 迭代重发:读 meta.json,零参数 +lark-cli apps +app-dev-publish --app-id app_xxx # 首次发布:指定目标,成功后写入 miaoda.json +lark-cli apps +app-dev-publish # 迭代重发:读 miaoda.json,零参数 lark-cli apps +app-dev-publish --skip-build lark-cli apps +app-dev-publish --dry-run ``` @@ -42,6 +42,6 @@ lark-cli apps +app-dev-publish --dry-run ## 常见失败 - `current directory is not a Miaoda app project`:不在项目根执行;`cd` 到含 `miaoda.json` 的目录。 -- `output/routes.json is missing` / schema 校验失败:模板构建脚本负责生成合法 routes.json;让用户检查构建配置,不要手工伪造。 +- `routes.json is missing` / schema 校验失败:声明了 `build.command` 的项目由构建脚本负责生成合法 routes.json;让用户检查构建配置,不要手工伪造(buildless 项目无此问题,CLI 会自动生成)。 - `build command ... failed`:转述 stderr 摘要让用户修构建错误(构建命令来自 miaoda.json `build.command`);用户已手动构建时可用 `--skip-build`。 -- `--skip-build is set but ./dist does not exist`:先构建或去掉 `--skip-build`。 +- `artifact directory ... does not exist`:声明了构建命令时先构建(或去掉 `--skip-build`);buildless 项目需确认 `build.output` 指向的目录真实存在。 From 2e43d3215c3c4d6edc8db1f9078c8bc41a7850be Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 19:15:40 +0800 Subject: [PATCH 27/51] feat(apps): add --registry escape hatch and html type to +app-dev-init-template - --registry fetches the template from one explicit npm registry with no fallback to the built-in chain (deterministic failure for mirror outages / private registries); https-only, and the tarball same-origin assertion binds to the given host - --type html maps to the html-standard-webapp template package --- shortcuts/apps/app_dev_template_fetch.go | 20 +++--- shortcuts/apps/apps_app_dev_init_template.go | 49 +++++++++++++-- .../apps/apps_app_dev_init_template_test.go | 61 ++++++++++++++++--- .../lark-apps-app-dev-init-template.md | 7 ++- 4 files changed, 116 insertions(+), 21 deletions(-) diff --git a/shortcuts/apps/app_dev_template_fetch.go b/shortcuts/apps/app_dev_template_fetch.go index cdf3a542b0..60d321cc26 100644 --- a/shortcuts/apps/app_dev_template_fetch.go +++ b/shortcuts/apps/app_dev_template_fetch.go @@ -64,14 +64,20 @@ type npmPackageMeta struct { } // fetchAppDevTemplate resolves and downloads the template package, trying -// each registry in appDevRegistries until one succeeds. requested pins a -// specific version or dist-tag ("" = latest). onFallback is called with a -// human-readable note before each retry (nil to skip). -func fetchAppDevTemplate(ctx context.Context, pkg, requested string, onFallback func(note string)) (version string, tgz []byte, err error) { +// each registry in registries until one succeeds (nil/empty = the built-in +// appDevRegistries fallback chain; an explicit --registry passes a single +// entry, so a failure is deterministic instead of silently shifting to +// another source). requested pins a specific version or dist-tag ("" = +// latest). onFallback is called with a human-readable note before each retry +// (nil to skip). +func fetchAppDevTemplate(ctx context.Context, pkg, requested string, registries []string, onFallback func(note string)) (version string, tgz []byte, err error) { + if len(registries) == 0 { + registries = appDevRegistries + } var lastErr error - for i, base := range appDevRegistries { + for i, base := range registries { if i > 0 && onFallback != nil { - onFallback(strings.TrimRight(appDevRegistries[i-1], "/") + " failed, falling back to " + strings.TrimRight(base, "/")) + onFallback(strings.TrimRight(registries[i-1], "/") + " failed, falling back to " + strings.TrimRight(base, "/")) } v, tarballURL, err := fetchAppDevTemplateMeta(ctx, base, pkg, requested) if err != nil { @@ -87,7 +93,7 @@ func fetchAppDevTemplate(ctx context.Context, pkg, requested string, onFallback return v, body, nil } if p, ok := errs.ProblemOf(lastErr); ok && strings.TrimSpace(p.Hint) == "" { - p.Hint = "all registries failed (" + strings.Join(appDevRegistries, ", ") + "); check network access and whether the template package is published" + p.Hint = "all registries failed (" + strings.Join(registries, ", ") + "); check network access and whether the template package is published" } return "", nil, lastErr } diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_app_dev_init_template.go index 3c17f00b65..e6a21eef53 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_app_dev_init_template.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "net/url" "os" "os/exec" "path/filepath" @@ -23,6 +24,7 @@ import ( const ( appDevTemplateFrontend = "react-standard-webapp" appDevTemplateFullstack = "react-express-standard-fullstack" + appDevTemplateHTML = "html-standard-webapp" ) // appDevLookPath is swappable in tests to simulate a missing binary @@ -37,6 +39,8 @@ func appDevTemplateForType(appType string) string { return appDevTemplateFrontend case "full_stack": return appDevTemplateFullstack + case "html": + return appDevTemplateHTML } return "" } @@ -68,6 +72,26 @@ func resolveAppDevTemplate(rctx *common.RuntimeContext) (string, error) { return appDevTemplateForType(appType), nil } +// resolveAppDevRegistries turns --registry into the registry list handed to +// the fetch: nil (flag unset) selects the built-in fallback chain, an +// explicit value is used exclusively — the escape hatch for mirror outages +// or a private registry must never silently shift to another source. Only +// https base URLs are accepted (the tarball same-origin assertion then binds +// to this host). +func resolveAppDevRegistries(rctx *common.RuntimeContext) ([]string, error) { + raw := strings.TrimSpace(rctx.Str("registry")) + if raw == "" { + return nil, nil + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" { + return nil, appsValidationParamError("--registry", + "--registry must be an https npm registry base URL, got %q", raw). + WithHint("e.g. --registry https://registry.npmjs.org (http and bare hosts are rejected); omit the flag to use the built-in registries") + } + return []string{strings.TrimRight(raw, "/")}, nil +} + // resolveAppDevDir returns the scaffold target directory: --dir when set, // otherwise the current directory (in-place init, matching miaoda-cli's // app init which scaffolds into process.cwd()). @@ -155,15 +179,19 @@ var AppsAppDevInitTemplate = common.Shortcut{ AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ - {Name: "type", Desc: "app type; maps to a template package (frontend=react-standard-webapp, full_stack=react-express-standard-fullstack); ignored when --template is set", Enum: []string{"frontend", "full_stack"}}, + {Name: "type", Desc: "app type; maps to a template package (frontend=react-standard-webapp, full_stack=react-express-standard-fullstack, html=html-standard-webapp); ignored when --template is set", Enum: []string{"frontend", "full_stack", "html"}}, {Name: "template", Desc: "template short name to use directly (resolves to @lark-apaas/coding-template-); takes precedence over --type"}, {Name: "template-version", Desc: "template package version or dist-tag to pin (e.g. 0.1.0-alpha.20260827082008 or alpha); default: latest"}, + {Name: "registry", Desc: "npm registry base URL to fetch the template from (https only); used exclusively when set — no fallback to the built-in registries. Escape hatch for mirror outages or private registries; only pass a registry the user explicitly provided or confirmed"}, {Name: "dir", Desc: "target directory, relative path (default: current directory, scaffolding in place); must be empty or new"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := resolveAppDevTemplate(rctx); err != nil { return err } + if _, err := resolveAppDevRegistries(rctx); err != nil { + return err + } return validateAppDevDir(rctx.Str("dir")) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { @@ -173,9 +201,16 @@ var AppsAppDevInitTemplate = common.Shortcut{ dry := common.NewDryRunAPI(). Desc("Scaffold a local web app project by downloading an npm template package (read-only registry fetch, no Lark API)") dry.Set("template_package", pkg) - dry.Set("registry_url", strings.TrimRight(appDevRegistries[0], "/")+"/"+pkg) - if len(appDevRegistries) > 1 { - dry.Set("registry_fallback", strings.Join(appDevRegistries[1:], ", ")) + registries, _ := resolveAppDevRegistries(rctx) // Validate already rejected invalid input + if registries != nil { + dry.Set("registry_source", "--registry flag (used exclusively, no fallback)") + } else { + registries = appDevRegistries + dry.Set("registry_source", "built-in fallback chain") + } + dry.Set("registry_url", strings.TrimRight(registries[0], "/")+"/"+pkg) + if len(registries) > 1 { + dry.Set("registry_fallback", strings.Join(registries[1:], ", ")) } dry.Set("target_dir", dir) dry.Set("template", template) @@ -203,9 +238,13 @@ var AppsAppDevInitTemplate = common.Shortcut{ if err := ensureAppDevDirUsable(dir); err != nil { return err } + registries, err := resolveAppDevRegistries(rctx) + if err != nil { + return err + } pkg := appDevTemplatePackageName(template) fmt.Fprintf(rctx.IO().ErrOut, "fetching template package %s...\n", pkg) - version, tgz, err := fetchAppDevTemplate(ctx, pkg, strings.TrimSpace(rctx.Str("template-version")), func(note string) { + version, tgz, err := fetchAppDevTemplate(ctx, pkg, strings.TrimSpace(rctx.Str("template-version")), registries, func(note string) { fmt.Fprintf(rctx.IO().ErrOut, "registry %s\n", note) }) if err != nil { diff --git a/shortcuts/apps/apps_app_dev_init_template_test.go b/shortcuts/apps/apps_app_dev_init_template_test.go index 80577cc290..29aeee25f5 100644 --- a/shortcuts/apps/apps_app_dev_init_template_test.go +++ b/shortcuts/apps/apps_app_dev_init_template_test.go @@ -30,7 +30,8 @@ func TestAppDevTemplateForType(t *testing.T) { }{ {"frontend", "frontend", "react-standard-webapp"}, {"full_stack", "full_stack", "react-express-standard-fullstack"}, - {"unknown", "html", ""}, + {"html", "html", "html-standard-webapp"}, + {"unknown", "vue", ""}, {"empty", "", ""}, } for _, tt := range tests { @@ -196,17 +197,17 @@ func TestFetchAppDevTemplate_PinnedVersionAndTag(t *testing.T) { pkg := "@lark-apaas/coding-template-react-standard-webapp" withFakeRegistry(t, pkg, buildTemplateTgz(t, defaultTemplateEntries())) // dist-tag resolution. - v, _, err := fetchAppDevTemplate(context.Background(), pkg, "alpha", nil) + v, _, err := fetchAppDevTemplate(context.Background(), pkg, "alpha", nil, nil) if err != nil || v != "2.0.0-alpha.1" { t.Errorf("dist-tag pin: v=%q err=%v", v, err) } // Exact version resolution. - v, _, err = fetchAppDevTemplate(context.Background(), pkg, "1.2.3", nil) + v, _, err = fetchAppDevTemplate(context.Background(), pkg, "1.2.3", nil, nil) if err != nil || v != "1.2.3" { t.Errorf("exact pin: v=%q err=%v", v, err) } // Unknown version: actionable error listing dist-tags. - _, _, err = fetchAppDevTemplate(context.Background(), pkg, "9.9.9", nil) + _, _, err = fetchAppDevTemplate(context.Background(), pkg, "9.9.9", nil, nil) if err == nil || !strings.Contains(err.Error(), `no version or dist-tag "9.9.9"`) { t.Errorf("unknown pin: err=%v", err) } @@ -445,7 +446,7 @@ func TestFetchAppDevTemplate_FallbackOn5xx(t *testing.T) { pkg := "@lark-apaas/coding-template-react-standard-webapp" newFailingThenOKRegistries(t, pkg, buildTemplateTgz(t, defaultTemplateEntries()), 503) var notes []string - version, tgz, err := fetchAppDevTemplate(context.Background(), pkg, "", func(n string) { notes = append(notes, n) }) + version, tgz, err := fetchAppDevTemplate(context.Background(), pkg, "", nil, func(n string) { notes = append(notes, n) }) if err != nil { t.Fatalf("fallback should succeed: %v", err) } @@ -462,7 +463,7 @@ func TestFetchAppDevTemplate_FallbackOn404(t *testing.T) { // 404 on the primary must also fall through to the official registry. pkg := "@lark-apaas/coding-template-react-standard-webapp" newFailingThenOKRegistries(t, pkg, buildTemplateTgz(t, defaultTemplateEntries()), 404) - version, _, err := fetchAppDevTemplate(context.Background(), pkg, "", nil) + version, _, err := fetchAppDevTemplate(context.Background(), pkg, "", nil, nil) if err != nil || version != "1.2.3" { t.Errorf("404 fallback: version=%q err=%v", version, err) } @@ -476,7 +477,7 @@ func TestFetchAppDevTemplate_AllRegistriesFail(t *testing.T) { appDevNewTransferClient = func() *http.Client { return srv.Client() } t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) - _, _, err := fetchAppDevTemplate(context.Background(), "@lark-apaas/coding-template-x", "", nil) + _, _, err := fetchAppDevTemplate(context.Background(), "@lark-apaas/coding-template-x", "", nil, nil) if err == nil { t.Fatal("all-fail must error") } @@ -533,10 +534,56 @@ func testRuntimeAppDevInitTpl(t *testing.T, appType, template, dir string) *comm cmd.Flags().String("type", appType, "") cmd.Flags().String("template", template, "") cmd.Flags().String("template-version", "", "") + cmd.Flags().String("registry", "", "") cmd.Flags().String("dir", dir, "") return common.TestNewRuntimeContext(cmd, nil) } +func TestResolveAppDevRegistries(t *testing.T) { + rctxWith := func(registry string) *common.RuntimeContext { + cmd := &cobra.Command{Use: "+app-dev-init-template"} + cmd.Flags().String("registry", registry, "") + return common.TestNewRuntimeContext(cmd, nil) + } + // Unset: nil selects the built-in fallback chain. + regs, err := resolveAppDevRegistries(rctxWith("")) + if err != nil || regs != nil { + t.Errorf("unset = (%v, %v), want (nil, nil)", regs, err) + } + // Explicit https URL: single entry, trailing slash trimmed. + regs, err = resolveAppDevRegistries(rctxWith("https://bnpm.example.com/")) + if err != nil || len(regs) != 1 || regs[0] != "https://bnpm.example.com" { + t.Errorf("explicit = (%v, %v)", regs, err) + } + // http and bare hosts are rejected. + for _, bad := range []string{"http://registry.npmjs.org", "registry.npmjs.org", "ftp://x"} { + if _, err := resolveAppDevRegistries(rctxWith(bad)); err == nil || !strings.Contains(err.Error(), "https") { + t.Errorf("registry %q must be rejected with an https hint, got %v", bad, err) + } + } +} + +func TestFetchAppDevTemplate_ExplicitRegistry(t *testing.T) { + pkg := "@lark-apaas/coding-template-react-standard-webapp" + srv := withFakeRegistry(t, pkg, buildTemplateTgz(t, defaultTemplateEntries())) + // An explicit registry pointing at the fake server works. + v, _, err := fetchAppDevTemplate(context.Background(), pkg, "", []string{srv.URL}, nil) + if err != nil || v != "1.2.3" { + t.Errorf("explicit registry fetch = (%q, %v)", v, err) + } + // An explicit dead registry must fail deterministically — never fall + // back to the built-in chain (which points at the working fake here). + var notes []string + _, _, err = fetchAppDevTemplate(context.Background(), pkg, "", []string{"https://127.0.0.1:1"}, + func(n string) { notes = append(notes, n) }) + if err == nil { + t.Fatal("dead explicit registry must fail, not fall back") + } + if len(notes) != 0 { + t.Errorf("no fallback notes expected for a single explicit registry, got %v", notes) + } +} + func TestAppDevInitTemplateValidate(t *testing.T) { tests := []struct { name, appType, dir, wantErr string diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-template.md b/skills/lark-apps/references/lark-apps-app-dev-init-template.md index 2a62c57312..a08f8ced9b 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-init-template.md +++ b/skills/lark-apps/references/lark-apps-app-dev-init-template.md @@ -9,15 +9,18 @@ ## 命令骨架 - 可选:`--template-version`,钉某个模板包版本或 dist-tag(如 `alpha`);缺省 latest。 -- `--type` 与 `--template` 二选一:`--type frontend|full_stack` 用默认模板映射;`--template <短名>`(如 `vite-react`)直接指定模板包,优先于 `--type`——模板包名为 `@lark-apaas/coding-template-<短名>`。 +- `--type` 与 `--template` 二选一:`--type frontend|full_stack|html` 用默认模板映射(frontend=react-standard-webapp、full_stack=react-express-standard-fullstack、html=html-standard-webapp);`--template <短名>`(如 `vite-react`)直接指定模板包,优先于 `--type`——模板包名为 `@lark-apaas/coding-template-<短名>`。 - 可选:`--dir`,相对路径;**缺省就地初始化到当前目录**(须为空目录,项目名取目录名);传 `--dir ./my-app` 则创建子目录。 -- 前置:已完成 `lark-cli config init`(框架级要求,纯本地命令也需要);本步骤**不需要 Node.js**。内部从 npm registry 只读下载模板包(主源 registry.npmmirror.com,失败自动降级 registry.npmjs.org 官方源) `@lark-apaas/coding-template-<模板名>` 并本地渲染,不执行任何远程脚本、不装依赖(秒级返回)。 +- 可选:`--registry `,指定 npm registry(内置双源都不可达、或模板发在私有源时的逃生通道)。**指定后只用该源、失败不降级**;仅接受 https。**安全规则:只有用户明确提供或确认的 registry 才能传**——不要因为默认源失败就自作主张换到任意源(模板会成为用户后续 `npm install` 的项目,源被劫持等于任意代码执行)。 +- 前置:已完成 `lark-cli config init`(框架级要求,纯本地命令也需要);本步骤**不需要 Node.js**。内部从 npm registry 只读下载模板包(缺省主源 registry.npmmirror.com,失败自动降级 registry.npmjs.org 官方源) `@lark-apaas/coding-template-<模板名>` 并本地渲染,不执行任何远程脚本、不装依赖(秒级返回)。 ## 示例 ```bash lark-cli apps +app-dev-init-template --type frontend --dir ./my-app +lark-cli apps +app-dev-init-template --type html --dir ./page lark-cli apps +app-dev-init-template --template vite-react --dir ./demo +lark-cli apps +app-dev-init-template --type frontend --registry https://registry.npmjs.org --dir ./my-app # 用户指定源 lark-cli apps +app-dev-init-template --type full_stack --dry-run ``` From f5fa9e078512a5e1ec74134d4c626d9c06942b56 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 20:23:13 +0800 Subject: [PATCH 28/51] feat(apps): wait briefly for async releases in +app-dev-publish After the release is accepted, poll it (3s interval, 60s bound) so the common case returns online_url in one command and writes the app state back. A failed pipeline now fails the publish with the error_logs summarized; a timeout or flaky poll degrades to the release_id + poll-hint output unchanged. --- shortcuts/apps/apps_app_dev_publish.go | 109 +++++++++++++++++- shortcuts/apps/apps_app_dev_publish_test.go | 98 ++++++++++++++++ .../references/lark-apps-app-dev-publish.md | 5 +- 3 files changed, 209 insertions(+), 3 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 4479485f9e..68879d8c4d 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -18,6 +18,7 @@ import ( "path/filepath" "sort" "strings" + "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" @@ -332,6 +333,92 @@ var appDevRunner envCommandRunner = execEnvCommandRunner{} // (the command only accepts https upload URLs). var appDevNewTransferClient = newFileTransferClient +// Bounded wait for an async release to finish. The html pipeline typically +// completes within seconds; past the timeout the command degrades to the +// release_id + poll hint output instead of failing. Vars so unit tests can +// shrink them. +var ( + appDevReleaseWaitTimeout = 60 * time.Second + appDevReleasePollInterval = 3 * time.Second +) + +// summarizeReleaseErrorLogs flattens a release's error_logs (slice of +// {step, error_log} objects) into one line for the failure message. +func summarizeReleaseErrorLogs(v interface{}) string { + items, _ := v.([]interface{}) + var parts []string + for _, it := range items { + m, _ := it.(map[string]interface{}) + if m == nil { + continue + } + step := common.GetString(m, "step") + msg := common.GetString(m, "error_log") + if step == "" && msg == "" { + continue + } + if step != "" { + parts = append(parts, "["+step+"] "+msg) + } else { + parts = append(parts, msg) + } + } + out := strings.Join(parts, "; ") + if len(out) > 500 { + out = out[:500] + "..." + } + return out +} + +// awaitAppDevRelease polls the release until it reaches a terminal state or +// the bounded wait elapses. finished returns the online_url; failed returns +// a structured error carrying the pipeline error_logs; a timeout or a poll +// request failure degrades gracefully — the release was accepted, so the +// caller falls back to the release_id + poll hint output. +func awaitAppDevRelease(ctx context.Context, rctx *common.RuntimeContext, appID, releaseID, status string) (finalStatus, onlineURL string, err error) { + path := fmt.Sprintf(releaseGetPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(releaseID)) + deadline := time.Now().Add(appDevReleaseWaitTimeout) + var errorLogs interface{} + for i := 0; ; i++ { + switch status { + case "finished": + return status, onlineURL, nil + case "failed": + if errorLogs == nil { + // The create call reported failed without details — fetch once. + if data, gerr := rctx.CallAPITyped("GET", path, nil, nil); gerr == nil { + errorLogs = data["error_logs"] + } + } + msg := summarizeReleaseErrorLogs(errorLogs) + if msg == "" { + msg = "no error_logs reported" + } + return status, "", appsExternalToolError(errors.New("release pipeline failed"), + "release %s failed: %s", releaseID, msg). + WithHint(fmt.Sprintf("the artifact was uploaded but the deploy pipeline failed; inspect with `lark-cli apps +release-get --app-id %s --release-id %s`, fix the reported step, then publish again", appID, releaseID)) + } + if !time.Now().Before(deadline) { + return status, "", nil + } + if i > 0 { + select { + case <-ctx.Done(): + return status, "", nil + case <-time.After(appDevReleasePollInterval): + } + } + data, gerr := rctx.CallAPITyped("GET", path, nil, nil) + if gerr != nil { + // The release exists; a flaky poll must not fail the publish. + return status, "", nil + } + status = common.GetString(data, "status") + onlineURL = common.GetString(data, "online_url") + errorLogs = data["error_logs"] + } +} + // AppsAppDevPublish builds and publishes a local web app project to its // Miaoda app. Run from the project root containing miaoda.json. var AppsAppDevPublish = common.Shortcut{ @@ -402,7 +489,7 @@ var AppsAppDevPublish = common.Shortcut{ }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { dry := common.NewDryRunAPI(). - Desc("Resolve app id (miaoda.json / --app-id) -> GET pre_release (presigned upload URL + MIAODA_* build env) -> run build.command -> validate output layout -> zip -> PUT to TOS -> POST releases; returns online_url (sync) or release_id (async)") + Desc("Resolve app id (miaoda.json / --app-id) -> GET pre_release (presigned upload URL + MIAODA_* build env) -> run build.command -> validate output layout -> zip -> PUT to TOS -> POST releases -> wait up to 60s for the async release; returns online_url, or release_id + poll hint when still publishing") cfg, appID, fromFlag, err := resolveAppDevPublishTarget(rctx) if cfg == nil { cfg = &appDevProjectConfig{Source: miaodaJSONRelPath} @@ -536,6 +623,26 @@ var AppsAppDevPublish = common.Shortcut{ releaseID := common.GetString(releaseData, "release_id") status := common.GetString(releaseData, "status") onlineURL := common.GetString(releaseData, "online_url") + // Async acceptance: wait briefly for the terminal state so the common + // case hands back online_url in one command (and app.url is written); + // past the bound, degrade to the poll-hint output. A failed pipeline + // is a failed publish — surfaced as an error, not a status field. + if onlineURL == "" && releaseID != "" { + if status != "finished" && status != "failed" { + fmt.Fprintf(rctx.IO().ErrOut, "release %s accepted (status %s); waiting up to %s for completion...\n", releaseID, status, appDevReleaseWaitTimeout) + } + finalStatus, finalURL, werr := awaitAppDevRelease(ctx, rctx, appID, releaseID, status) + if werr != nil { + return werr + } + if finalStatus != "" { + status = finalStatus + } + onlineURL = finalURL + if onlineURL == "" { + fmt.Fprintf(rctx.IO().ErrOut, "release still %s; continue polling manually\n", status) + } + } data := map[string]interface{}{ "app_id": appID, "release_id": releaseID, diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_app_dev_publish_test.go index f941a56f4d..46926fbf51 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_app_dev_publish_test.go @@ -14,6 +14,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/httpmock" @@ -468,6 +469,26 @@ func stubPreRelease(reg *httpmock.Registry, appID, uploadURL string, extraKVs ma }) } +// withFastAppDevPoll shrinks the async-release wait knobs so tests never +// sleep for real. +func withFastAppDevPoll(t *testing.T, timeout, interval time.Duration) { + t.Helper() + origT, origI := appDevReleaseWaitTimeout, appDevReleasePollInterval + appDevReleaseWaitTimeout, appDevReleasePollInterval = timeout, interval + t.Cleanup(func() { appDevReleaseWaitTimeout, appDevReleasePollInterval = origT, origI }) +} + +func stubReleaseGet(reg *httpmock.Registry, appID, releaseID string, respData map[string]interface{}) { + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/" + appID + "/releases/" + releaseID, + Body: map[string]interface{}{ + "code": float64(0), + "data": respData, + }, + }) +} + func stubReleases(reg *httpmock.Registry, appID string, respData map[string]interface{}) { reg.Register(&httpmock.Stub{ Method: "POST", @@ -728,6 +749,10 @@ func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", srv.URL, nil) stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_2", "status": "pending"}) + // The bounded wait keeps polling "pending" until the (shrunk) timeout, + // then degrades to the poll-hint output. + stubReleaseGet(reg, "app_x", "rel_2", map[string]interface{}{"release_id": "rel_2", "status": "pending"}) + withFastAppDevPoll(t, 20*time.Millisecond, time.Millisecond) if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } @@ -749,6 +774,79 @@ func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { } } +func TestAppDevPublishExecute_AwaitFinished(t *testing.T) { + // Async acceptance followed by a finished poll: online_url comes back in + // one command and lands in miaoda.json's app section. + root := chdirMiaodaProjectRoot(t, `{"stack":"s","app":{"id":"app_x"}}`) + writeDistFiles(t, filepath.Join(root, appDevDefaultBuildOutput), []string{"index.html", "routes.json"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_40", "status": "publishing"}) + stubReleaseGet(reg, "app_x", "rel_40", map[string]interface{}{ + "release_id": "rel_40", "status": "finished", + "online_url": "https://x/app/app_x", + }) + withFastAppDevPoll(t, time.Second, time.Millisecond) + if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["online_url"] != "https://x/app/app_x" || data["status"] != "finished" { + t.Errorf("data = %v", data) + } + if _, has := data["poll_hint"]; has { + t.Error("finished await must not carry poll_hint") + } + b, _ := os.ReadFile(filepath.Join(root, miaodaJSONRelPath)) + var doc map[string]interface{} + _ = json.Unmarshal(b, &doc) + app, _ := doc["app"].(map[string]interface{}) + if app == nil || app["url"] != "https://x/app/app_x" { + t.Errorf("app.url must be written after the awaited finish, got %v", doc["app"]) + } +} + +func TestAppDevPublishExecute_AwaitFailed(t *testing.T) { + // A failed pipeline is a failed publish: exit non-zero with the + // error_logs summarized and an actionable hint. + root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_41", "status": "publishing"}) + stubReleaseGet(reg, "app_x", "rel_41", map[string]interface{}{ + "release_id": "rel_41", "status": "failed", + "error_logs": []interface{}{ + map[string]interface{}{"step": "build", "error_log": "formula output is empty"}, + }, + }) + withFastAppDevPoll(t, time.Second, time.Millisecond) + err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryInternal) + if !strings.Contains(p.Message, "release rel_41 failed") || !strings.Contains(p.Message, "[build] formula output is empty") { + t.Errorf("message = %q", p.Message) + } + if !strings.Contains(p.Hint, "+release-get --app-id app_x --release-id rel_41") { + t.Errorf("hint = %q", p.Hint) + } +} + +func TestSummarizeReleaseErrorLogs(t *testing.T) { + if got := summarizeReleaseErrorLogs(nil); got != "" { + t.Errorf("nil logs = %q", got) + } + logs := []interface{}{ + map[string]interface{}{"step": "build", "error_log": "a"}, + map[string]interface{}{"error_log": "b"}, + "garbage", + } + if got := summarizeReleaseErrorLogs(logs); got != "[build] a; b" { + t.Errorf("summary = %q", got) + } +} + func TestAppDevPublishExecute_BuildFails(t *testing.T) { chdirMiaodaProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) diff --git a/skills/lark-apps/references/lark-apps-app-dev-publish.md b/skills/lark-apps/references/lark-apps-app-dev-publish.md index cd10efd076..11afca6554 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-publish.md +++ b/skills/lark-apps/references/lark-apps-app-dev-publish.md @@ -25,8 +25,9 @@ lark-cli apps +app-dev-publish --dry-run ## 输出契约 -- 同步完成:`data.online_url` 直接可访问,同时随 app 段回写进 `miaoda.json`。 -- 异步发布:返回 `data.release_id` 和 `data.poll_hint`;用 `+release-get --app-id --release-id ` 轮询到 `finished` 后读取 `online_url`。 +- 异步受理后命令会**原地等待最多 60s**(每 3s 轮询发布单):等到 `finished` 则直接返回 `data.online_url`(并随 app 段回写进 `miaoda.json`),一条命令闭环。 +- 超过 60s 仍在发布中:返回 `data.release_id` 和 `data.poll_hint`(不算失败);用 `+release-get --app-id --release-id ` 继续轮询到 `finished` 后读取 `online_url`。 +- **流水线失败 = 发布失败**:exit 非 0,message 含各 step 的 error_logs 摘要,hint 给出复查命令;产物已上传,修复后重新 publish 即可。 - 业务失败通常带 `error.hint`,优先转述 hint;网络/服务端 5xx 失败带 `retryable`,可稍后重试。 ## 前置引导 From 84fa851d3014046adb6ae2a89fc7ac383eacef4a Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 20:25:59 +0800 Subject: [PATCH 29/51] fix(apps): satisfy nilerr/forbidigo in release await path --- shortcuts/apps/apps_app_dev_publish.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_app_dev_publish.go index 68879d8c4d..3a857b1041 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_app_dev_publish.go @@ -394,7 +394,7 @@ func awaitAppDevRelease(ctx context.Context, rctx *common.RuntimeContext, appID, if msg == "" { msg = "no error_logs reported" } - return status, "", appsExternalToolError(errors.New("release pipeline failed"), + return status, "", errs.NewInternalError(errs.SubtypeExternalTool, "release %s failed: %s", releaseID, msg). WithHint(fmt.Sprintf("the artifact was uploaded but the deploy pipeline failed; inspect with `lark-cli apps +release-get --app-id %s --release-id %s`, fix the reported step, then publish again", appID, releaseID)) } @@ -410,8 +410,9 @@ func awaitAppDevRelease(ctx context.Context, rctx *common.RuntimeContext, appID, } data, gerr := rctx.CallAPITyped("GET", path, nil, nil) if gerr != nil { - // The release exists; a flaky poll must not fail the publish. - return status, "", nil + // The release was accepted; a flaky poll must not fail the + // publish — degrade to the poll-hint output. + return status, "", nil //nolint:nilerr // deliberate degradation, see above. } status = common.GetString(data, "status") onlineURL = common.GetString(data, "online_url") From 6c421fd7d1c315f327b1c90febd48f302003b2cd Mon Sep 17 00:00:00 2001 From: duanlikang Date: Thu, 27 Aug 2026 22:11:16 +0800 Subject: [PATCH 30/51] refactor(apps): rename +app-dev-init-template/+app-dev-publish to +init-template/+deploy --- shortcuts/apps/app_dev_project_config.go | 2 +- ...apps_app_dev_publish.go => apps_deploy.go} | 16 ++-- ...ev_publish_test.go => apps_deploy_test.go} | 82 +++++++++---------- ...init_template.go => apps_init_template.go} | 20 ++--- ...ate_test.go => apps_init_template_test.go} | 52 ++++++------ shortcuts/apps/shortcuts.go | 4 +- ...app-dev-publish.md => lark-apps-deploy.md} | 16 ++-- ...template.md => lark-apps-init-template.md} | 16 ++-- 8 files changed, 104 insertions(+), 104 deletions(-) rename shortcuts/apps/{apps_app_dev_publish.go => apps_deploy.go} (98%) rename shortcuts/apps/{apps_app_dev_publish_test.go => apps_deploy_test.go} (91%) rename shortcuts/apps/{apps_app_dev_init_template.go => apps_init_template.go} (93%) rename shortcuts/apps/{apps_app_dev_init_template_test.go => apps_init_template_test.go} (93%) rename skills/lark-apps/references/{lark-apps-app-dev-publish.md => lark-apps-deploy.md} (83%) rename skills/lark-apps/references/{lark-apps-app-dev-init-template.md => lark-apps-init-template.md} (78%) diff --git a/shortcuts/apps/app_dev_project_config.go b/shortcuts/apps/app_dev_project_config.go index 6d5fd20b40..4d1e3a388f 100644 --- a/shortcuts/apps/app_dev_project_config.go +++ b/shortcuts/apps/app_dev_project_config.go @@ -22,7 +22,7 @@ const miaodaJSONRelPath = "miaoda.json" const appDevDefaultBuildOutput = "dist/output" // appDevProjectConfig is the resolved view of the project declaration that -// +app-dev-publish consumes. Fields are filled with protocol defaults when +// +deploy consumes. Fields are filled with protocol defaults when // the declaration omits them. type appDevProjectConfig struct { Stack string diff --git a/shortcuts/apps/apps_app_dev_publish.go b/shortcuts/apps/apps_deploy.go similarity index 98% rename from shortcuts/apps/apps_app_dev_publish.go rename to shortcuts/apps/apps_deploy.go index 3a857b1041..9056bf6a92 100644 --- a/shortcuts/apps/apps_app_dev_publish.go +++ b/shortcuts/apps/apps_deploy.go @@ -275,13 +275,13 @@ func resolveAppDevPublishTarget(rctx *common.RuntimeContext) (cfg *appDevProject if !found { return nil, "", false, appsFailedPreconditionError( "current directory is not a Miaoda app project (miaoda.json not found)"). - WithHint("run this command from the project root; scaffold a project with +app-dev-init-template first") + WithHint("run this command from the project root; scaffold a project with +init-template first") } recorded := cfg.AppID switch { case flagID == "" && recorded == "": return nil, "", false, appsFailedPreconditionError("no publish target: %s has no app id and --app-id was not given", cfg.Source). - WithHint("create the app first with `lark-cli apps +create --name `, then publish with `lark-cli apps +app-dev-publish --app-id ` (the id is saved into miaoda.json on success)") + WithHint("create the app first with `lark-cli apps +create --name `, then publish with `lark-cli apps +deploy --app-id ` (the id is saved into miaoda.json on success)") case flagID != "" && recorded != "" && flagID != recorded: return nil, "", false, appsFailedPreconditionParamError("--app-id", "%s already records app id %s but --app-id is %s; refusing to silently switch the publish target", cfg.Source, recorded, flagID). @@ -324,7 +324,7 @@ func (execEnvCommandRunner) RunEnv(ctx context.Context, dir string, extraEnv []s return stdout.String(), stderr.String(), err } -// appDevRunner is the envCommandRunner used by +app-dev-publish's build step. +// appDevRunner is the envCommandRunner used by +deploy's build step. // Package-level so unit tests can swap in a fake. var appDevRunner envCommandRunner = execEnvCommandRunner{} @@ -420,16 +420,16 @@ func awaitAppDevRelease(ctx context.Context, rctx *common.RuntimeContext, appID, } } -// AppsAppDevPublish builds and publishes a local web app project to its +// AppsDeploy builds and publishes a local web app project to its // Miaoda app. Run from the project root containing miaoda.json. -var AppsAppDevPublish = common.Shortcut{ +var AppsDeploy = common.Shortcut{ Service: appsService, - Command: "+app-dev-publish", + Command: "+deploy", Description: "Build and publish a local web app project to its Miaoda app (run from the project root containing miaoda.json)", Risk: "write", Tips: []string{ - "Example: lark-cli apps +app-dev-publish (run from the project root)", - "Example: lark-cli apps +app-dev-publish --skip-build (reuse the existing build.output directory)", + "Example: lark-cli apps +deploy (run from the project root)", + "Example: lark-cli apps +deploy --skip-build (reuse the existing build.output directory)", "Prerequisite: an app id in miaoda.json or via --app-id (create the app with +create first)", }, Scopes: []string{"spark:app:write", "spark:app:read"}, diff --git a/shortcuts/apps/apps_app_dev_publish_test.go b/shortcuts/apps/apps_deploy_test.go similarity index 91% rename from shortcuts/apps/apps_app_dev_publish_test.go rename to shortcuts/apps/apps_deploy_test.go index 46926fbf51..8cf75a666c 100644 --- a/shortcuts/apps/apps_app_dev_publish_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -503,7 +503,7 @@ func stubReleases(reg *httpmock.Registry, appID string, respData map[string]inte func TestAppDevPublishValidate_NoMeta(t *testing.T) { chdirProjectRoot(t, "") factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "not a Miaoda app project") { t.Errorf("got %v", p) @@ -511,7 +511,7 @@ func TestAppDevPublishValidate_NoMeta(t *testing.T) { if !strings.Contains(p.Message, "miaoda.json") { t.Errorf("message should name miaoda.json, got %q", p.Message) } - if !strings.Contains(p.Hint, "+app-dev-init-template") { + if !strings.Contains(p.Hint, "+init-template") { t.Errorf("hint = %q", p.Hint) } } @@ -519,14 +519,14 @@ func TestAppDevPublishValidate_NoMeta(t *testing.T) { func TestAppDevPublishValidate_NoAppID(t *testing.T) { chdirProjectRoot(t, `{"stack":"react-standard-webapp"}`) factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if !strings.Contains(p.Message, "no publish target") { t.Errorf("message = %q", p.Message) } // The guidance must lead to +create and the new --app-id flow (no manual // JSON editing). - if !strings.Contains(p.Hint, "+create") || !strings.Contains(p.Hint, "+app-dev-publish --app-id") { + if !strings.Contains(p.Hint, "+create") || !strings.Contains(p.Hint, "+deploy --app-id") { t.Errorf("hint = %q", p.Hint) } } @@ -534,8 +534,8 @@ func TestAppDevPublishValidate_NoAppID(t *testing.T) { func TestAppDevPublishValidate_AppIDMismatch(t *testing.T) { chdirProjectRoot(t, `{"app_id":"app_recorded"}`) factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAppDevPublish, - []string{"+app-dev-publish", "--app-id", "app_other", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, + []string{"+deploy", "--app-id", "app_other", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if !strings.Contains(p.Message, "app_recorded") || !strings.Contains(p.Message, "app_other") { t.Errorf("message must name both ids, got %q", p.Message) @@ -552,8 +552,8 @@ func TestAppDevPublishExecute_FlagAppIDBackfill(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_flag1", srv.URL, nil) stubReleases(reg, "app_flag1", map[string]interface{}{"release_id": "rel_9", "status": "pending"}) - if err := runAppsShortcut(t, AppsAppDevPublish, - []string{"+app-dev-publish", "--app-id", "app_flag1", "--skip-build", "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, + []string{"+deploy", "--app-id", "app_flag1", "--skip-build", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } // app_id persisted on success, other fields preserved. @@ -572,8 +572,8 @@ func TestAppDevPublishExecute_FlagMatchesMeta(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", srv.URL, nil) stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_10", "status": "pending"}) - if err := runAppsShortcut(t, AppsAppDevPublish, - []string{"+app-dev-publish", "--app-id", "app_x", "--skip-build", "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, + []string{"+deploy", "--app-id", "app_x", "--skip-build", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("matching --app-id must publish fine: %v", err) } } @@ -581,7 +581,7 @@ func TestAppDevPublishExecute_FlagMatchesMeta(t *testing.T) { func TestAppDevPublishValidate_BadAppID(t *testing.T) { chdirProjectRoot(t, `{"app_id":"meta_token_x"}`) factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if !strings.Contains(p.Message, ".spark/meta.json app id") { t.Errorf("message should point at the config source, got %q", p.Message) @@ -602,8 +602,8 @@ func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) // Sensitive hits are the one exception to dry-run's exit-0 convention: // Validate rejects before the DryRun branch runs. - err := runAppsShortcut(t, AppsAppDevPublish, - []string{"+app-dev-publish", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, + []string{"+deploy", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if !strings.Contains(p.Message, "publish payload contains") || !strings.Contains(p.Message, "credential file") { t.Errorf("message = %q", p.Message) @@ -613,8 +613,8 @@ func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { t.Errorf("error must not reference a nonexistent --path flag: %q", p.Message) } // --allow-sensitive waives the gate and dry-run goes back to exit 0. - if err := runAppsShortcut(t, AppsAppDevPublish, - []string{"+app-dev-publish", "--skip-build", "--allow-sensitive", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, + []string{"+deploy", "--skip-build", "--allow-sensitive", "--as", "user", "--dry-run"}, factory, stdout); err != nil { t.Errorf("allow-sensitive dry-run should pass: %v", err) } } @@ -622,7 +622,7 @@ func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { func TestAppDevPublishValidate_SkipBuildNoDist(t *testing.T) { chdirMiaodaProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "--skip-build is set but the artifact directory dist/output does not exist") { t.Errorf("got %v", p) @@ -634,7 +634,7 @@ func TestAppDevPublishValidate_BuildlessNoDist(t *testing.T) { // the artifact directory must already exist. chdirProjectRoot(t, `{"app_id":"app_x"}`) factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "artifact directory dist/output does not exist") { t.Errorf("got %v", p) @@ -672,7 +672,7 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { "release_id": "rel_1", "status": "finished", "online_url": "https://x.feishuapp.cn/app/app_x", }) - if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } // Build invocation contract. @@ -724,7 +724,7 @@ func TestAppDevPublishExecute_BuildlessSparkSync(t *testing.T) { "release_id": "rel_30", "status": "finished", "online_url": "https://x/app/app_x", }) - if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } if f.called { @@ -753,7 +753,7 @@ func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { // then degrades to the poll-hint output. stubReleaseGet(reg, "app_x", "rel_2", map[string]interface{}{"release_id": "rel_2", "status": "pending"}) withFastAppDevPoll(t, 20*time.Millisecond, time.Millisecond) - if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } data := parseEnvelopeData(t, stdout) @@ -788,7 +788,7 @@ func TestAppDevPublishExecute_AwaitFinished(t *testing.T) { "online_url": "https://x/app/app_x", }) withFastAppDevPoll(t, time.Second, time.Millisecond) - if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } data := parseEnvelopeData(t, stdout) @@ -823,7 +823,7 @@ func TestAppDevPublishExecute_AwaitFailed(t *testing.T) { }, }) withFastAppDevPoll(t, time.Second, time.Millisecond) - err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryInternal) if !strings.Contains(p.Message, "release rel_41 failed") || !strings.Contains(p.Message, "[build] formula output is empty") { t.Errorf("message = %q", p.Message) @@ -854,7 +854,7 @@ func TestAppDevPublishExecute_BuildFails(t *testing.T) { withFakeEnvRunner(t, f) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", srv.URL, nil) - err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryInternal) if !strings.Contains(p.Message, `build command "npm run build" failed`) || !strings.Contains(p.Message, "TS2304") { t.Errorf("message = %q", p.Message) @@ -876,7 +876,7 @@ func TestAppDevPublishExecute_PreReleaseMissingKVs(t *testing.T) { "data": map[string]interface{}{"kvs": []interface{}{}}, }, }) - err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryInternal) if !strings.Contains(p.Message, "missing artifact_url") { t.Errorf("message = %q", p.Message) @@ -888,7 +888,7 @@ func TestAppDevPublishExecute_NonHTTPSUploadURL(t *testing.T) { writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", "http://insecure.example/put", nil) - err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryInternal) if !strings.Contains(p.Message, "not https") { t.Errorf("message = %q", p.Message) @@ -901,7 +901,7 @@ func TestAppDevPublishExecute_TOS5xx(t *testing.T) { srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", srv.URL, nil) - err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryNetwork) if !p.Retryable { t.Error("5xx upload failure must be retryable") @@ -912,7 +912,7 @@ func TestAppDevPublishDryRun(t *testing.T) { root := chdirMiaodaProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"],"output":"dist/output"}}`) writeDistFiles(t, filepath.Join(root, "dist", "output"), []string{"index.html"}) factory, stdout, _ := newAppsExecuteFactory(t) - if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } data, err := decodeDryRunDataMap(stdout.Bytes()) @@ -937,7 +937,7 @@ func TestAppDevPublishDryRun_Buildless(t *testing.T) { root := chdirProjectRoot(t, `{"app_id":"app_x"}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html"}) factory, stdout, _ := newAppsExecuteFactory(t) - if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user", "--dry-run"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } data, err := decodeDryRunDataMap(stdout.Bytes()) @@ -982,7 +982,7 @@ func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { "release_id": "rel_20", "status": "finished", "online_url": "https://x/app/app_x", }) - if err := runAppsShortcut(t, AppsAppDevPublish, []string{"+app-dev-publish", "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } // Declared build command executed (not npm run build). @@ -1011,8 +1011,8 @@ func TestAppDevPublishExecute_MiaodaFlagBackfill(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_new1", srv.URL, nil) stubReleases(reg, "app_new1", map[string]interface{}{"release_id": "rel_21", "status": "pending"}) - if err := runAppsShortcut(t, AppsAppDevPublish, - []string{"+app-dev-publish", "--app-id", "app_new1", "--skip-build", "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsDeploy, + []string{"+deploy", "--app-id", "app_new1", "--skip-build", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } b, _ := os.ReadFile(filepath.Join(root, miaodaJSONRelPath)) @@ -1030,25 +1030,25 @@ func TestAppDevPublishExecute_MiaodaFlagBackfill(t *testing.T) { func TestAppDevPublishValidate_MiaodaMismatch(t *testing.T) { chdirMiaodaProjectRoot(t, `{"app": {"id": "app_recorded"}}`) factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAppDevPublish, - []string{"+app-dev-publish", "--app-id", "app_other", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsDeploy, + []string{"+deploy", "--app-id", "app_other", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if !strings.Contains(p.Message, "miaoda.json") || !strings.Contains(p.Message, "app_recorded") { t.Errorf("message = %q", p.Message) } } -func TestAppsAppDevPublish_Declaration(t *testing.T) { - if AppsAppDevPublish.Command != "+app-dev-publish" { - t.Errorf("Command = %q", AppsAppDevPublish.Command) +func TestAppsDeploy_Declaration(t *testing.T) { + if AppsDeploy.Command != "+deploy" { + t.Errorf("Command = %q", AppsDeploy.Command) } - if AppsAppDevPublish.Risk != "write" { - t.Errorf("Risk = %q", AppsAppDevPublish.Risk) + if AppsDeploy.Risk != "write" { + t.Errorf("Risk = %q", AppsDeploy.Risk) } - if !AppsAppDevPublish.HasFormat { + if !AppsDeploy.HasFormat { t.Error("HasFormat = false") } - if len(AppsAppDevPublish.Scopes) != 2 { - t.Errorf("Scopes = %v", AppsAppDevPublish.Scopes) + if len(AppsDeploy.Scopes) != 2 { + t.Errorf("Scopes = %v", AppsDeploy.Scopes) } } diff --git a/shortcuts/apps/apps_app_dev_init_template.go b/shortcuts/apps/apps_init_template.go similarity index 93% rename from shortcuts/apps/apps_app_dev_init_template.go rename to shortcuts/apps/apps_init_template.go index e6a21eef53..4561611320 100644 --- a/shortcuts/apps/apps_app_dev_init_template.go +++ b/shortcuts/apps/apps_init_template.go @@ -28,10 +28,10 @@ const ( ) // appDevLookPath is swappable in tests to simulate a missing binary -// (+app-dev-publish uses it for its npm precondition check). +// (+deploy uses it for its npm precondition check). var appDevLookPath = exec.LookPath -// appDevTemplateForType maps the +app-dev-init-template --type value to its +// appDevTemplateForType maps the +init-template --type value to its // template short name. Unknown types return "". func appDevTemplateForType(appType string) string { switch appType { @@ -159,19 +159,19 @@ func ensureAppDevDirUsable(dir string) error { return nil } -// AppsAppDevInitTemplate scaffolds a local web app project from an npm +// AppsInitTemplate scaffolds a local web app project from an npm // template package (artifact-hosting mode: code stays local, no git, no // sandbox, no Node required for this step). -var AppsAppDevInitTemplate = common.Shortcut{ +var AppsInitTemplate = common.Shortcut{ Service: appsService, - Command: "+app-dev-init-template", + Command: "+init-template", Description: "Scaffold a local web app project from an npm template package (artifact-hosting mode, no git/sandbox/Node, no Lark API)", Risk: "write", Tips: []string{ - "Example: lark-cli apps +app-dev-init-template --type frontend --dir ./my-app", - "Example: lark-cli apps +app-dev-init-template --type full_stack --dry-run", - "Example: lark-cli apps +app-dev-init-template --template vite-react --dir ./demo (use a specific template package directly)", - "The scaffold is local-only: create the Miaoda app later with +create and deploy with +app-dev-publish", + "Example: lark-cli apps +init-template --type frontend --dir ./my-app", + "Example: lark-cli apps +init-template --type full_stack --dry-run", + "Example: lark-cli apps +init-template --template vite-react --dir ./demo (use a specific template package directly)", + "The scaffold is local-only: create the Miaoda app later with +create and deploy with +deploy", }, // No Lark OAPI is called; explicit []string{} per the convention // enforced by TestAllShortcutsScopesNotNil. @@ -267,7 +267,7 @@ var AppsAppDevInitTemplate = common.Shortcut{ nextSteps := []string{ devPrefix + "npm install && npm run dev", "lark-cli apps +create --name to create the Miaoda app", - "run lark-cli apps +app-dev-publish --app-id from the project root (saved into miaoda.json on success; later runs need no flag)", + "run lark-cli apps +deploy --app-id from the project root (saved into miaoda.json on success; later runs need no flag)", } data := map[string]interface{}{ "dir": dir, diff --git a/shortcuts/apps/apps_app_dev_init_template_test.go b/shortcuts/apps/apps_init_template_test.go similarity index 93% rename from shortcuts/apps/apps_app_dev_init_template_test.go rename to shortcuts/apps/apps_init_template_test.go index 29aeee25f5..f05a1ab100 100644 --- a/shortcuts/apps/apps_app_dev_init_template_test.go +++ b/shortcuts/apps/apps_init_template_test.go @@ -505,20 +505,20 @@ func TestRenderAppDevTemplate_SkippedEntryBombCap(t *testing.T) { // --- declaration & validate tests --- -func TestAppsAppDevInitTemplate_Declaration(t *testing.T) { - if AppsAppDevInitTemplate.Command != "+app-dev-init-template" { - t.Errorf("Command = %q", AppsAppDevInitTemplate.Command) +func TestAppsInitTemplate_Declaration(t *testing.T) { + if AppsInitTemplate.Command != "+init-template" { + t.Errorf("Command = %q", AppsInitTemplate.Command) } - if AppsAppDevInitTemplate.Service != appsService { - t.Errorf("Service = %q", AppsAppDevInitTemplate.Service) + if AppsInitTemplate.Service != appsService { + t.Errorf("Service = %q", AppsInitTemplate.Service) } - if AppsAppDevInitTemplate.Risk != "write" { - t.Errorf("Risk = %q, want write", AppsAppDevInitTemplate.Risk) + if AppsInitTemplate.Risk != "write" { + t.Errorf("Risk = %q, want write", AppsInitTemplate.Risk) } - if !AppsAppDevInitTemplate.HasFormat { + if !AppsInitTemplate.HasFormat { t.Error("HasFormat = false, want true") } - if AppsAppDevInitTemplate.Scopes == nil { + if AppsInitTemplate.Scopes == nil { t.Error("Scopes must be non-nil (no Lark API => empty slice)") } } @@ -530,7 +530,7 @@ func testRuntimeAppDevInit(t *testing.T, appType, dir string) *common.RuntimeCon func testRuntimeAppDevInitTpl(t *testing.T, appType, template, dir string) *common.RuntimeContext { t.Helper() - cmd := &cobra.Command{Use: "+app-dev-init-template"} + cmd := &cobra.Command{Use: "+init-template"} cmd.Flags().String("type", appType, "") cmd.Flags().String("template", template, "") cmd.Flags().String("template-version", "", "") @@ -541,7 +541,7 @@ func testRuntimeAppDevInitTpl(t *testing.T, appType, template, dir string) *comm func TestResolveAppDevRegistries(t *testing.T) { rctxWith := func(registry string) *common.RuntimeContext { - cmd := &cobra.Command{Use: "+app-dev-init-template"} + cmd := &cobra.Command{Use: "+init-template"} cmd.Flags().String("registry", registry, "") return common.TestNewRuntimeContext(cmd, nil) } @@ -594,7 +594,7 @@ func TestAppDevInitTemplateValidate(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := AppsAppDevInitTemplate.Validate(context.Background(), testRuntimeAppDevInit(t, tt.appType, tt.dir)) + err := AppsInitTemplate.Validate(context.Background(), testRuntimeAppDevInit(t, tt.appType, tt.dir)) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("err = %v, want containing %q", err, tt.wantErr) } @@ -637,8 +637,8 @@ func TestAppDevInitTemplateExecute_RendersFromRegistry(t *testing.T) { withFakeRegistry(t, pkg, buildTemplateTgz(t, defaultTemplateEntries())) factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) - if err := runAppsShortcut(t, AppsAppDevInitTemplate, - []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsInitTemplate, + []string{"+init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } data := parseEnvelopeData(t, stdout) @@ -677,8 +677,8 @@ func TestAppDevInitTemplateExecute_FullStackPackage(t *testing.T) { })) factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) - if err := runAppsShortcut(t, AppsAppDevInitTemplate, - []string{"+app-dev-init-template", "--type", "full_stack", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsInitTemplate, + []string{"+init-template", "--type", "full_stack", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } data := parseEnvelopeData(t, stdout) @@ -695,8 +695,8 @@ func TestAppDevInitTemplateExecute_ExplicitTemplate(t *testing.T) { })) factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) - if err := runAppsShortcut(t, AppsAppDevInitTemplate, - []string{"+app-dev-init-template", "--template", "vite-react", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsInitTemplate, + []string{"+init-template", "--template", "vite-react", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } data := parseEnvelopeData(t, stdout) @@ -718,8 +718,8 @@ func TestAppDevInitTemplateExecute_RegistryDown(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) dir := relAppDevDir(t) - err := runAppsShortcut(t, AppsAppDevInitTemplate, - []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsInitTemplate, + []string{"+init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryNetwork) if !p.Retryable { t.Error("registry 5xx must be retryable") @@ -738,8 +738,8 @@ func TestAppDevInitTemplateExecute_DirNotEmpty(t *testing.T) { t.Fatal(err) } factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAppDevInitTemplate, - []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsInitTemplate, + []string{"+init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition { t.Errorf("subtype = %q", p.Subtype) @@ -748,8 +748,8 @@ func TestAppDevInitTemplateExecute_DirNotEmpty(t *testing.T) { func TestAppDevInitTemplateDryRun(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - if err := runAppsShortcut(t, AppsAppDevInitTemplate, - []string{"+app-dev-init-template", "--type", "frontend", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsInitTemplate, + []string{"+init-template", "--type", "frontend", "--as", "user", "--dry-run"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } data, err := decodeDryRunDataMap(stdout.Bytes()) @@ -782,8 +782,8 @@ func TestAppDevInitTemplateDryRun_DirNotEmptySurfaced(t *testing.T) { t.Fatal(err) } factory, stdout, _ := newAppsExecuteFactory(t) - if err := runAppsShortcut(t, AppsAppDevInitTemplate, - []string{"+app-dev-init-template", "--type", "frontend", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsInitTemplate, + []string{"+init-template", "--type", "frontend", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } data, err := decodeDryRunDataMap(stdout.Bytes()) diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 3620de1fb3..ad0431be75 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -34,8 +34,8 @@ func Shortcuts() []common.Shortcut { AppsMemberSettingsSet, AppsHTMLPublish, AppsInit, - AppsAppDevInitTemplate, - AppsAppDevPublish, + AppsInitTemplate, + AppsDeploy, AppsReleaseCreate, AppsReleaseList, AppsReleaseGet, diff --git a/skills/lark-apps/references/lark-apps-app-dev-publish.md b/skills/lark-apps/references/lark-apps-deploy.md similarity index 83% rename from skills/lark-apps/references/lark-apps-app-dev-publish.md rename to skills/lark-apps/references/lark-apps-deploy.md index 11afca6554..296974c089 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-publish.md +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -1,10 +1,10 @@ -# apps +app-dev-publish +# apps +deploy -把本地 Web 应用项目一键构建并发布到它的妙搭应用(产物托管形态)。运行时命令事实以 `lark-cli apps +app-dev-publish --help` 为准。 +把本地 Web 应用项目一键构建并发布到它的妙搭应用(产物托管形态)。运行时命令事实以 `lark-cli apps +deploy --help` 为准。 ## 何时用 -用 `+app-dev-init-template` 初始化(或按产物协议改造)的本地项目要部署/更新到妙搭时使用。它不适用于 html 应用(走 `+html-publish`)或源码托管应用(走 `+release-create`)。 +用 `+init-template` 初始化(或按产物协议改造)的本地项目要部署/更新到妙搭时使用。它不适用于 html 应用(走 `+html-publish`)或源码托管应用(走 `+release-create`)。 ## 命令骨架 @@ -17,10 +17,10 @@ ## 示例 ```bash -lark-cli apps +app-dev-publish --app-id app_xxx # 首次发布:指定目标,成功后写入 miaoda.json -lark-cli apps +app-dev-publish # 迭代重发:读 miaoda.json,零参数 -lark-cli apps +app-dev-publish --skip-build -lark-cli apps +app-dev-publish --dry-run +lark-cli apps +deploy --app-id app_xxx # 首次发布:指定目标,成功后写入 miaoda.json +lark-cli apps +deploy # 迭代重发:读 miaoda.json,零参数 +lark-cli apps +deploy --skip-build +lark-cli apps +deploy --dry-run ``` ## 输出契约 @@ -32,7 +32,7 @@ lark-cli apps +app-dev-publish --dry-run ## 前置引导 -- 未记录 app id 时:先 `lark-cli apps +create --name ` 创建应用,然后 `lark-cli apps +app-dev-publish --app-id <返回的 app_id>` 发布(成功后自动写入 miaoda.json,无需手工编辑文件);应用名可从项目主题生成,不要让用户手动提供 app_id。 +- 未记录 app id 时:先 `lark-cli apps +create --name ` 创建应用,然后 `lark-cli apps +deploy --app-id <返回的 app_id>` 发布(成功后自动写入 miaoda.json,无需手工编辑文件);应用名可从项目主题生成,不要让用户手动提供 app_id。 - **记录的 app id 不是本会话写入的**(来自历史文件或他人仓库)时,发布前先把目标 app id 告知用户并确认——发布会覆盖该应用的线上内容。 ## 安全规则 diff --git a/skills/lark-apps/references/lark-apps-app-dev-init-template.md b/skills/lark-apps/references/lark-apps-init-template.md similarity index 78% rename from skills/lark-apps/references/lark-apps-app-dev-init-template.md rename to skills/lark-apps/references/lark-apps-init-template.md index a08f8ced9b..22cfcbf935 100644 --- a/skills/lark-apps/references/lark-apps-app-dev-init-template.md +++ b/skills/lark-apps/references/lark-apps-init-template.md @@ -1,6 +1,6 @@ -# apps +app-dev-init-template +# apps +init-template -在本地初始化一个产物托管形态的 Web 应用项目(代码留在本地,构建产物后续发布到妙搭)。运行时命令事实以 `lark-cli apps +app-dev-init-template --help` 为准。 +在本地初始化一个产物托管形态的 Web 应用项目(代码留在本地,构建产物后续发布到妙搭)。运行时命令事实以 `lark-cli apps +init-template --help` 为准。 ## 何时用 @@ -17,11 +17,11 @@ ## 示例 ```bash -lark-cli apps +app-dev-init-template --type frontend --dir ./my-app -lark-cli apps +app-dev-init-template --type html --dir ./page -lark-cli apps +app-dev-init-template --template vite-react --dir ./demo -lark-cli apps +app-dev-init-template --type frontend --registry https://registry.npmjs.org --dir ./my-app # 用户指定源 -lark-cli apps +app-dev-init-template --type full_stack --dry-run +lark-cli apps +init-template --type frontend --dir ./my-app +lark-cli apps +init-template --type html --dir ./page +lark-cli apps +init-template --template vite-react --dir ./demo +lark-cli apps +init-template --type frontend --registry https://registry.npmjs.org --dir ./my-app # 用户指定源 +lark-cli apps +init-template --type full_stack --dry-run ``` ## 输出契约 @@ -30,7 +30,7 @@ lark-cli apps +app-dev-init-template --type full_stack --dry-run 1. `cd && npm install && npm run dev` 本地开发预览(dev 命令声明见项目根 `miaoda.json`); 2. 需要发布时先 `lark-cli apps +create --name ` 创建妙搭应用; -3. 在项目根运行 `lark-cli apps +app-dev-publish --app-id <返回的 app_id>` 构建并发布(成功后 app id 写入 miaoda.json,后续免传;见 [lark-apps-app-dev-publish.md](lark-apps-app-dev-publish.md))。 +3. 在项目根运行 `lark-cli apps +deploy --app-id <返回的 app_id>` 构建并发布(成功后 app id 写入 miaoda.json,后续免传;见 [lark-apps-deploy.md](lark-apps-deploy.md))。 ## 常见失败 From 1929edc9eaf0467792ed4a5a41c471a850c0e567 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Fri, 28 Aug 2026 00:02:14 +0800 Subject: [PATCH 31/51] feat(apps): rename the project declaration file to spark.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the hosting-protocol naming change, the declaration file is now spark.json (was miaoda.json). The legacy .spark/meta.json fallback is removed — spark.json is the only declaration file +deploy reads and writes back to. --- shortcuts/apps/app_dev_project_config.go | 94 ++++------ shortcuts/apps/apps_deploy.go | 102 +++------- shortcuts/apps/apps_deploy_test.go | 177 ++++++------------ shortcuts/apps/apps_init_template.go | 4 +- shortcuts/apps/apps_init_template_test.go | 16 +- .../lark-apps/references/lark-apps-deploy.md | 18 +- .../references/lark-apps-init-template.md | 4 +- 7 files changed, 142 insertions(+), 273 deletions(-) diff --git a/shortcuts/apps/app_dev_project_config.go b/shortcuts/apps/app_dev_project_config.go index 4d1e3a388f..31b73c3b5e 100644 --- a/shortcuts/apps/app_dev_project_config.go +++ b/shortcuts/apps/app_dev_project_config.go @@ -10,10 +10,10 @@ import ( "strings" ) -// miaodaJSONRelPath is the project declaration file of the artifact-hosting +// sparkJSONRelPath is the project declaration file of the artifact-hosting // protocol (妙搭产物托管协议规范 §3): how to dev/build, plus the app state // section written back by the deploy chain. -const miaodaJSONRelPath = "miaoda.json" +const sparkJSONRelPath = "spark.json" // appDevDefaultBuildOutput is the protocol default for build.output (the // same-origin artifact directory). Since protocol v0.3 there is no default @@ -38,16 +38,12 @@ type appDevProjectConfig struct { BuildOutputCDN string AppID string AppURL string - // Source is the file the config came from: miaodaJSONRelPath or - // metaRelPath (legacy fallback). It decides where the app state is - // written back after a successful publish. - Source string } -// miaodaJSONDoc mirrors the miaoda.json schema (§3). Unknown fields are +// sparkJSONDoc mirrors the spark.json schema (§3). Unknown fields are // ignored on read and preserved on write (the writer re-marshals the raw // map, not this struct). -type miaodaJSONDoc struct { +type sparkJSONDoc struct { Stack string `json:"stack"` Version string `json:"version"` Build struct { @@ -61,44 +57,30 @@ type miaodaJSONDoc struct { } `json:"app"` } -// readAppDevProjectConfig loads the project declaration from dir: -// miaoda.json first, falling back to the legacy .spark/meta.json (cloud -// sandbox form, kept untouched per §3). found=false means neither exists — -// the directory is not a Miaoda app project. +// readAppDevProjectConfig loads the project declaration from +// /spark.json. found=false means the file does not exist — the +// directory is not a Miaoda app project. func readAppDevProjectConfig(dir string) (cfg *appDevProjectConfig, found bool, err error) { - mp := filepath.Join(dir, miaodaJSONRelPath) - if b, rerr := os.ReadFile(mp); rerr == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard); path is cwd-relative. - var doc miaodaJSONDoc - if jerr := json.Unmarshal(b, &doc); jerr != nil { - return nil, true, appsFileIOError(jerr, "parse %s failed: %v", miaodaJSONRelPath, jerr) - } - cfg := &appDevProjectConfig{ - Stack: doc.Stack, - Version: doc.Version, - BuildCommand: doc.Build.Command, - BuildOutput: strings.TrimSpace(doc.Build.Output), - BuildOutputCDN: strings.TrimSpace(doc.Build.OutputCDN), - AppID: strings.TrimSpace(doc.App.ID), - AppURL: strings.TrimSpace(doc.App.URL), - Source: miaodaJSONRelPath, + mp := filepath.Join(dir, sparkJSONRelPath) + b, rerr := os.ReadFile(mp) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard); path is cwd-relative. + if rerr != nil { + if os.IsNotExist(rerr) { + return nil, false, nil } - applyAppDevConfigDefaults(cfg) - return cfg, true, nil - } else if !os.IsNotExist(rerr) { - return nil, false, appsFileIOError(rerr, "read %s failed: %v", miaodaJSONRelPath, rerr) - } - - // Legacy fallback: .spark/meta.json (top-level app_id). - appID, isSpark, err := readMetaAppID(dir) - if err != nil { - return nil, false, err + return nil, false, appsFileIOError(rerr, "read %s failed: %v", sparkJSONRelPath, rerr) } - if !isSpark { - return nil, false, nil + var doc sparkJSONDoc + if jerr := json.Unmarshal(b, &doc); jerr != nil { + return nil, true, appsFileIOError(jerr, "parse %s failed: %v", sparkJSONRelPath, jerr) } cfg = &appDevProjectConfig{ - AppID: strings.TrimSpace(appID), - Source: metaRelPath, + Stack: doc.Stack, + Version: doc.Version, + BuildCommand: doc.Build.Command, + BuildOutput: strings.TrimSpace(doc.Build.Output), + BuildOutputCDN: strings.TrimSpace(doc.Build.OutputCDN), + AppID: strings.TrimSpace(doc.App.ID), + AppURL: strings.TrimSpace(doc.App.URL), } applyAppDevConfigDefaults(cfg) return cfg, true, nil @@ -117,19 +99,19 @@ func applyAppDevConfigDefaults(cfg *appDevProjectConfig) { // uses the output directories as-is. func (c *appDevProjectConfig) Buildless() bool { return len(c.BuildCommand) == 0 } -// writeMiaodaAppSection replaces the app state section of /miaoda.json +// writeSparkAppSection replaces the app state section of /spark.json // with {id, url} after a successful publish (§3: the app section is owned by // the deploy chain and replaced wholesale; declaration fields are never // touched). Empty url omits the key. Creates the file if missing. -func writeMiaodaAppSection(dir, appID, appURL string) error { - path := filepath.Join(dir, miaodaJSONRelPath) +func writeSparkAppSection(dir, appID, appURL string) error { + path := filepath.Join(dir, sparkJSONRelPath) doc := map[string]interface{}{} if b, err := os.ReadFile(path); err == nil { //nolint:forbidigo // see readAppDevProjectConfig. if jerr := json.Unmarshal(b, &doc); jerr != nil { - return appsFileIOError(jerr, "parse %s failed: %v", miaodaJSONRelPath, jerr) + return appsFileIOError(jerr, "parse %s failed: %v", sparkJSONRelPath, jerr) } } else if !os.IsNotExist(err) { - return appsFileIOError(err, "read %s failed: %v", miaodaJSONRelPath, err) + return appsFileIOError(err, "read %s failed: %v", sparkJSONRelPath, err) } app := map[string]interface{}{"id": appID} if appURL != "" { @@ -138,28 +120,28 @@ func writeMiaodaAppSection(dir, appID, appURL string) error { doc["app"] = app out, err := json.MarshalIndent(doc, "", " ") if err != nil { - return appsFileIOError(err, "marshal %s failed: %v", miaodaJSONRelPath, err) + return appsFileIOError(err, "marshal %s failed: %v", sparkJSONRelPath, err) } if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // see above. - return appsFileIOError(err, "write %s failed: %v", miaodaJSONRelPath, err) + return appsFileIOError(err, "write %s failed: %v", sparkJSONRelPath, err) } return nil } -// writeMiaodaScaffoldFields merge-writes the scaffold-owned fields into -// /miaoda.json after template rendering: version is always stamped with +// writeSparkScaffoldFields merge-writes the scaffold-owned fields into +// /spark.json after template rendering: version is always stamped with // the rendered package version (authoritative), stack is only filled when the // template seed did not declare one, and every other field the seed shipped // (dev/build declarations, unknown fields) is preserved (§3 字段所有权). -func writeMiaodaScaffoldFields(dir, stack, version string) error { - path := filepath.Join(dir, miaodaJSONRelPath) +func writeSparkScaffoldFields(dir, stack, version string) error { + path := filepath.Join(dir, sparkJSONRelPath) doc := map[string]interface{}{} if b, err := os.ReadFile(path); err == nil { //nolint:forbidigo // see readAppDevProjectConfig. if jerr := json.Unmarshal(b, &doc); jerr != nil { - return appsFileIOError(jerr, "parse %s failed: %v", miaodaJSONRelPath, jerr) + return appsFileIOError(jerr, "parse %s failed: %v", sparkJSONRelPath, jerr) } } else if !os.IsNotExist(err) { - return appsFileIOError(err, "read %s failed: %v", miaodaJSONRelPath, err) + return appsFileIOError(err, "read %s failed: %v", sparkJSONRelPath, err) } if cur, _ := doc["stack"].(string); strings.TrimSpace(cur) == "" { doc["stack"] = stack @@ -167,10 +149,10 @@ func writeMiaodaScaffoldFields(dir, stack, version string) error { doc["version"] = version out, err := json.MarshalIndent(doc, "", " ") if err != nil { - return appsFileIOError(err, "marshal %s failed: %v", miaodaJSONRelPath, err) + return appsFileIOError(err, "marshal %s failed: %v", sparkJSONRelPath, err) } if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // see above. - return appsFileIOError(err, "write %s failed: %v", miaodaJSONRelPath, err) + return appsFileIOError(err, "write %s failed: %v", sparkJSONRelPath, err) } return nil } diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index 9056bf6a92..e9198af1c8 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -60,33 +60,6 @@ func appDevBuildEnv(kvm map[string]string) (env []string, keys []string) { return env, keys } -// ensureMetaOnlineURL merge-writes online_url into /.spark/meta.json, -// preserving existing fields. A missing file is not an error — the backfill -// is best-effort. -func ensureMetaOnlineURL(dir, onlineURL string) error { - path := filepath.Join(dir, metaRelPath) - b, err := os.ReadFile(path) //nolint:forbidigo // same rationale as readMetaAppID - if err != nil { - if os.IsNotExist(err) { - return nil - } - return appsFileIOError(err, "read %s failed: %v", metaRelPath, err) - } - var meta map[string]interface{} - if err := json.Unmarshal(b, &meta); err != nil { - return appsFileIOError(err, "parse %s failed: %v", metaRelPath, err) - } - meta["online_url"] = onlineURL - out, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err) - } - if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // same rationale - return appsFileIOError(err, "write %s failed: %v", metaRelPath, err) - } - return nil -} - // validateAppDevOutputs walks the declared artifact directories and builds // the normalized upload payload: every file under build.output lands at // output/ inside the zip and every file under build.output_cdn (when @@ -104,12 +77,12 @@ func validateAppDevOutputs(fio fileio.FileIO, cfg *appDevProjectConfig, allowSen if err != nil { // A missing artifact directory means "build first", not a bad flag value. if errors.Is(err, fs.ErrNotExist) { - hint := "run the build first, or drop --skip-build to let the command build (build.output is declared in miaoda.json)" + hint := "run the build first, or drop --skip-build to let the command build (build.output is declared in spark.json)" if cfg.Buildless() { - hint = "this project declares no build.command, so the directory is packed as-is; create it, or point miaoda.json build.output at the right directory" + hint = "this project declares no build.command, so the directory is packed as-is; create it, or point spark.json build.output at the right directory" } return nil, -1, appsFailedPreconditionError( - "artifact directory %s not found (miaoda.json build.output, default dist/output)", cfg.BuildOutput). + "artifact directory %s not found (spark.json build.output, default dist/output)", cfg.BuildOutput). WithHint(hint) } return nil, -1, err @@ -134,7 +107,7 @@ func validateAppDevOutputs(fio fileio.FileIO, cfg *appDevProjectConfig, allowSen if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, -1, appsFailedPreconditionError( - "CDN artifact directory %s not found (declared in miaoda.json build.output_cdn)", cfg.BuildOutputCDN). + "CDN artifact directory %s not found (declared in spark.json build.output_cdn)", cfg.BuildOutputCDN). WithHint("make the build produce it, or drop build.output_cdn to publish without the CDN split") } return nil, -1, err @@ -257,7 +230,7 @@ func appDevSensitiveCandidatesError(hits []string) error { WithHint("remove these files from the artifact directories, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)") } -// resolveAppDevPublishTarget loads the project declaration (miaoda.json +// resolveAppDevPublishTarget loads the project declaration (spark.json // first, legacy .spark/meta.json fallback) and resolves the publish target // from --app-id and the recorded app id: // - flag only -> use it (written back after a successful publish) @@ -274,17 +247,17 @@ func resolveAppDevPublishTarget(rctx *common.RuntimeContext) (cfg *appDevProject } if !found { return nil, "", false, appsFailedPreconditionError( - "current directory is not a Miaoda app project (miaoda.json not found)"). + "current directory is not a Miaoda app project (spark.json not found)"). WithHint("run this command from the project root; scaffold a project with +init-template first") } recorded := cfg.AppID switch { case flagID == "" && recorded == "": - return nil, "", false, appsFailedPreconditionError("no publish target: %s has no app id and --app-id was not given", cfg.Source). - WithHint("create the app first with `lark-cli apps +create --name `, then publish with `lark-cli apps +deploy --app-id ` (the id is saved into miaoda.json on success)") + return nil, "", false, appsFailedPreconditionError("no publish target: %s has no app id and --app-id was not given", sparkJSONRelPath). + WithHint("create the app first with `lark-cli apps +create --name `, then publish with `lark-cli apps +deploy --app-id ` (the id is saved into spark.json on success)") case flagID != "" && recorded != "" && flagID != recorded: return nil, "", false, appsFailedPreconditionParamError("--app-id", - "%s already records app id %s but --app-id is %s; refusing to silently switch the publish target", cfg.Source, recorded, flagID). + "%s already records app id %s but --app-id is %s; refusing to silently switch the publish target", sparkJSONRelPath, recorded, flagID). WithHint("drop --app-id to publish to the recorded app, or update the recorded app id first if you really mean to switch") case flagID != "": if err := validateRealAppID(flagID); err != nil { @@ -294,7 +267,7 @@ func resolveAppDevPublishTarget(rctx *common.RuntimeContext) (cfg *appDevProject default: if !strings.HasPrefix(recorded, "app_") { return nil, "", false, appsFailedPreconditionError( - `%s app id %q is invalid (must start with "app_")`, cfg.Source, recorded). + `%s app id %q is invalid (must start with "app_")`, sparkJSONRelPath, recorded). WithHint("fix the recorded app id: find the right one with `lark-cli apps +list`, or create the app with `lark-cli apps +create --name `") } return cfg, recorded, false, nil @@ -421,23 +394,23 @@ func awaitAppDevRelease(ctx context.Context, rctx *common.RuntimeContext, appID, } // AppsDeploy builds and publishes a local web app project to its -// Miaoda app. Run from the project root containing miaoda.json. +// Miaoda app. Run from the project root containing spark.json. var AppsDeploy = common.Shortcut{ Service: appsService, Command: "+deploy", - Description: "Build and publish a local web app project to its Miaoda app (run from the project root containing miaoda.json)", + Description: "Build and publish a local web app project to its Miaoda app (run from the project root containing spark.json)", Risk: "write", Tips: []string{ "Example: lark-cli apps +deploy (run from the project root)", "Example: lark-cli apps +deploy --skip-build (reuse the existing build.output directory)", - "Prerequisite: an app id in miaoda.json or via --app-id (create the app with +create first)", + "Prerequisite: an app id in spark.json or via --app-id (create the app with +create first)", }, Scopes: []string{"spark:app:write", "spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ - {Name: "app-id", Desc: "publish target app ID (app_ prefix); optional when miaoda.json already records one — on a successful publish it is saved back into miaoda.json, and a value conflicting with the recorded one is rejected"}, - {Name: "skip-build", Type: "bool", Desc: "skip the build.command declared in miaoda.json and publish the existing build.output directory as-is (no effect on buildless projects, which never build)"}, + {Name: "app-id", Desc: "publish target app ID (app_ prefix); optional when spark.json already records one — on a successful publish it is saved back into spark.json, and a value conflicting with the recorded one is rejected"}, + {Name: "skip-build", Type: "bool", Desc: "skip the build.command declared in spark.json and publish the existing build.output directory as-is (no effect on buildless projects, which never build)"}, {Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / etc. in the publish payload)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -472,8 +445,8 @@ var AppsDeploy = common.Shortcut{ switch { case cfg.Buildless(): if _, err := rctx.FileIO().Stat(cfg.BuildOutput); err != nil { - return appsFailedPreconditionError("artifact directory %s does not exist (miaoda.json build.output, default dist/output)", cfg.BuildOutput). - WithHint("this project declares no build.command, so the directory is packed as-is; create it, or declare build.command in miaoda.json") + return appsFailedPreconditionError("artifact directory %s does not exist (spark.json build.output, default dist/output)", cfg.BuildOutput). + WithHint("this project declares no build.command, so the directory is packed as-is; create it, or declare build.command in spark.json") } case rctx.Bool("skip-build"): if _, err := rctx.FileIO().Stat(cfg.BuildOutput); err != nil { @@ -483,17 +456,17 @@ var AppsDeploy = common.Shortcut{ default: if _, err := appDevLookPath(cfg.BuildCommand[0]); err != nil { return appsFailedPreconditionError("build command executable %q not found on PATH", cfg.BuildCommand[0]). - WithHint("install it (build.command is declared in miaoda.json), or build manually and retry with --skip-build") + WithHint("install it (build.command is declared in spark.json), or build manually and retry with --skip-build") } } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { dry := common.NewDryRunAPI(). - Desc("Resolve app id (miaoda.json / --app-id) -> GET pre_release (presigned upload URL + MIAODA_* build env) -> run build.command -> validate output layout -> zip -> PUT to TOS -> POST releases -> wait up to 60s for the async release; returns online_url, or release_id + poll hint when still publishing") + Desc("Resolve app id (spark.json / --app-id) -> GET pre_release (presigned upload URL + MIAODA_* build env) -> run build.command -> validate output layout -> zip -> PUT to TOS -> POST releases -> wait up to 60s for the async release; returns online_url, or release_id + poll hint when still publishing") cfg, appID, fromFlag, err := resolveAppDevPublishTarget(rctx) if cfg == nil { - cfg = &appDevProjectConfig{Source: miaodaJSONRelPath} + cfg = &appDevProjectConfig{} applyAppDevConfigDefaults(cfg) } switch { @@ -502,9 +475,9 @@ var AppsDeploy = common.Shortcut{ default: dry.Set("app_id", appID) if fromFlag { - dry.Set("app_id_source", "--app-id flag (will be saved into miaoda.json on success)") + dry.Set("app_id_source", "--app-id flag (will be saved into spark.json on success)") } else { - dry.Set("app_id_source", cfg.Source) + dry.Set("app_id_source", sparkJSONRelPath) } dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))). PUT(" (https only)"). @@ -512,9 +485,9 @@ var AppsDeploy = common.Shortcut{ Body(map[string]string{}) } if cfg.Buildless() { - dry.Set("build_command", "(buildless: miaoda.json declares no build.command; the artifact directories are packed as-is)") + dry.Set("build_command", "(buildless: spark.json declares no build.command; the artifact directories are packed as-is)") } else { - dry.Set("build_command", strings.Join(cfg.BuildCommand, " ")+" (from miaoda.json build.command; env allowlist: MIAODA_* keys from pre_release; skipped with --skip-build)") + dry.Set("build_command", strings.Join(cfg.BuildCommand, " ")+" (from spark.json build.command; env allowlist: MIAODA_* keys from pre_release; skipped with --skip-build)") } dry.Set("build_output", cfg.BuildOutput+" -> zip output/ (same-origin artifacts)") if cfg.BuildOutputCDN != "" { @@ -540,7 +513,7 @@ var AppsDeploy = common.Shortcut{ // The server-side owner check is the only authorization line — echo // the target loudly so a wrong app_id is visible before anything // ships, naming where the id came from. - source := cfg.Source + source := sparkJSONRelPath if fromFlag { source = "--app-id" } @@ -577,7 +550,7 @@ var AppsDeploy = common.Shortcut{ fmt.Fprintf(rctx.IO().ErrOut, "running build: %s\n", strings.Join(buildCmd, " ")) if _, stderr, err := appDevRunner.RunEnv(ctx, "", env, buildCmd[0], buildCmd[1:]...); err != nil { return appsExternalToolError(err, "build command %q failed: %s", strings.Join(buildCmd, " "), gitErr(stderr, err)). - WithHint("fix the build errors and retry; or build manually and retry with --skip-build (build.command is declared in miaoda.json)") + WithHint("fix the build errors and retry; or build manually and retry with --skip-build (build.command is declared in spark.json)") } built = true } @@ -660,25 +633,10 @@ var AppsDeploy = common.Shortcut{ data["poll_hint"] = pollHint } // The release was accepted — write the app state back per protocol - // (§3): miaoda.json gets the app section replaced wholesale; the - // legacy .spark/meta.json fallback keeps its old field names and is - // only ever filled, never rewritten. Best-effort: a write failure - // must not fail the publish. - if cfg.Source == miaodaJSONRelPath { - if err := writeMiaodaAppSection(".", appID, onlineURL); err != nil { - fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to write app state into %s: %v\n", miaodaJSONRelPath, err) - } - } else { - if fromFlag { - if err := ensureMetaAppID(".", appID); err != nil { - fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to save app_id into %s: %v\n", metaRelPath, err) - } - } - if onlineURL != "" { - if err := ensureMetaOnlineURL(".", onlineURL); err != nil { - fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to backfill online_url into %s: %v\n", metaRelPath, err) - } - } + // (§3): spark.json gets the app section replaced wholesale. + // Best-effort: a write failure must not fail the publish. + if err := writeSparkAppSection(".", appID, onlineURL); err != nil { + fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to write app state into %s: %v\n", sparkJSONRelPath, err) } rctx.OutFormatRaw(data, nil, func(w io.Writer) { fmt.Fprintf(w, "app_id: %s\nrelease_id: %s\nstatus: %s\n", appID, releaseID, status) diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index 8cf75a666c..535b2090f9 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -48,38 +48,10 @@ func TestAppDevBuildEnv(t *testing.T) { } } -func TestEnsureMetaOnlineURL(t *testing.T) { - dir := t.TempDir() - // Missing meta.json: best-effort no-op. - if err := ensureMetaOnlineURL(dir, "https://x/app/app_x"); err != nil { - t.Errorf("missing meta must not error: %v", err) - } - if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x","stack":"s"}`), 0o644); err != nil { - t.Fatal(err) - } - if err := ensureMetaOnlineURL(dir, "https://x/app/app_x"); err != nil { - t.Fatal(err) - } - b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) - if err != nil { - t.Fatal(err) - } - var meta map[string]interface{} - if err := json.Unmarshal(b, &meta); err != nil { - t.Fatal(err) - } - if meta["online_url"] != "https://x/app/app_x" || meta["app_id"] != "app_x" || meta["stack"] != "s" { - t.Errorf("meta after backfill = %v", meta) - } -} - // --- artifact layout validation --- // testAppDevCfg builds a resolved project config for validation tests. -// buildless mirrors a miaoda.json without build.command. +// buildless mirrors a spark.json without build.command. func testAppDevCfg(output, cdn string, buildless bool) *appDevProjectConfig { cfg := &appDevProjectConfig{BuildOutput: output, BuildOutputCDN: cdn} if !buildless { @@ -395,37 +367,13 @@ func withFakeEnvRunner(t *testing.T, f *fakeEnvRunner) { t.Cleanup(func() { appDevRunner = orig }) } -// chdirMiaodaProjectRoot creates a temp project root with miaoda.json and +// chdirSparkProjectRoot creates a temp project root with spark.json and // chdirs into it (the protocol-first path). -func chdirMiaodaProjectRoot(t *testing.T, miaodaJSON string) string { +func chdirSparkProjectRoot(t *testing.T, miaodaJSON string) string { t.Helper() root := t.TempDir() if miaodaJSON != "" { - if err := os.WriteFile(filepath.Join(root, miaodaJSONRelPath), []byte(miaodaJSON), 0o644); err != nil { - t.Fatal(err) - } - } - old, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - if err := os.Chdir(root); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { os.Chdir(old) }) - return root -} - -// chdirProjectRoot creates a temp project root with .spark/meta.json and -// chdirs into it for the test (legacy fallback path). -func chdirProjectRoot(t *testing.T, metaJSON string) string { - t.Helper() - root := t.TempDir() - if metaJSON != "" { - if err := os.MkdirAll(filepath.Join(root, ".spark"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, metaRelPath), []byte(metaJSON), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, sparkJSONRelPath), []byte(miaodaJSON), 0o644); err != nil { t.Fatal(err) } } @@ -501,15 +449,15 @@ func stubReleases(reg *httpmock.Registry, appID string, respData map[string]inte } func TestAppDevPublishValidate_NoMeta(t *testing.T) { - chdirProjectRoot(t, "") + chdirSparkProjectRoot(t, "") factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "not a Miaoda app project") { t.Errorf("got %v", p) } - if !strings.Contains(p.Message, "miaoda.json") { - t.Errorf("message should name miaoda.json, got %q", p.Message) + if !strings.Contains(p.Message, "spark.json") { + t.Errorf("message should name spark.json, got %q", p.Message) } if !strings.Contains(p.Hint, "+init-template") { t.Errorf("hint = %q", p.Hint) @@ -517,7 +465,7 @@ func TestAppDevPublishValidate_NoMeta(t *testing.T) { } func TestAppDevPublishValidate_NoAppID(t *testing.T) { - chdirProjectRoot(t, `{"stack":"react-standard-webapp"}`) + chdirSparkProjectRoot(t, `{"stack":"react-standard-webapp"}`) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) @@ -532,7 +480,7 @@ func TestAppDevPublishValidate_NoAppID(t *testing.T) { } func TestAppDevPublishValidate_AppIDMismatch(t *testing.T) { - chdirProjectRoot(t, `{"app_id":"app_recorded"}`) + chdirSparkProjectRoot(t, `{"app":{"id":"app_recorded"}}`) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--app-id", "app_other", "--as", "user"}, factory, stdout) @@ -545,28 +493,8 @@ func TestAppDevPublishValidate_AppIDMismatch(t *testing.T) { } } -func TestAppDevPublishExecute_FlagAppIDBackfill(t *testing.T) { - root := chdirProjectRoot(t, `{"stack":"react-standard-webapp"}`) // no app_id - writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) - srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) - factory, stdout, reg := newAppsExecuteFactory(t) - stubPreRelease(reg, "app_flag1", srv.URL, nil) - stubReleases(reg, "app_flag1", map[string]interface{}{"release_id": "rel_9", "status": "pending"}) - if err := runAppsShortcut(t, AppsDeploy, - []string{"+deploy", "--app-id", "app_flag1", "--skip-build", "--as", "user"}, factory, stdout); err != nil { - t.Fatalf("unexpected: %v", err) - } - // app_id persisted on success, other fields preserved. - b, _ := os.ReadFile(filepath.Join(root, metaRelPath)) - var meta map[string]interface{} - _ = json.Unmarshal(b, &meta) - if meta["app_id"] != "app_flag1" || meta["stack"] != "react-standard-webapp" { - t.Errorf("meta after publish = %v", meta) - } -} - func TestAppDevPublishExecute_FlagMatchesMeta(t *testing.T) { - root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -579,11 +507,11 @@ func TestAppDevPublishExecute_FlagMatchesMeta(t *testing.T) { } func TestAppDevPublishValidate_BadAppID(t *testing.T) { - chdirProjectRoot(t, `{"app_id":"meta_token_x"}`) + chdirSparkProjectRoot(t, `{"app":{"id":"meta_token_x"}}`) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) - if !strings.Contains(p.Message, ".spark/meta.json app id") { + if !strings.Contains(p.Message, "spark.json app id") { t.Errorf("message should point at the config source, got %q", p.Message) } // This command has no --app-id flag; the error must not mention one. @@ -596,7 +524,7 @@ func TestAppDevPublishValidate_BadAppID(t *testing.T) { } func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { - root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json", "output/.env"}) factory, stdout, _ := newAppsExecuteFactory(t) @@ -620,7 +548,7 @@ func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { } func TestAppDevPublishValidate_SkipBuildNoDist(t *testing.T) { - chdirMiaodaProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) + chdirSparkProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) @@ -630,9 +558,9 @@ func TestAppDevPublishValidate_SkipBuildNoDist(t *testing.T) { } func TestAppDevPublishValidate_BuildlessNoDist(t *testing.T) { - // No build.command declared (legacy .spark fallback resolves to buildless): - // the artifact directory must already exist. - chdirProjectRoot(t, `{"app_id":"app_x"}`) + // No build.command declared in spark.json (buildless): the artifact + // directory must already exist. + chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) @@ -645,7 +573,7 @@ func TestAppDevPublishValidate_BuildlessNoDist(t *testing.T) { } func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { - root := chdirMiaodaProjectRoot(t, `{ + root := chdirSparkProjectRoot(t, `{ "stack": "react-standard-webapp", "build": { "command": ["npm", "run", "build"], "output": "dist/output" }, "app": { "id": "app_x" } @@ -700,8 +628,8 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { if _, hasPoll := data["poll_hint"]; hasPoll { t.Error("sync success must not carry poll_hint") } - // miaoda.json app-section writeback. - b, _ := os.ReadFile(filepath.Join(root, miaodaJSONRelPath)) + // spark.json app-section writeback. + b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) var doc map[string]interface{} _ = json.Unmarshal(b, &doc) app, _ := doc["app"].(map[string]interface{}) @@ -710,10 +638,10 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { } } -func TestAppDevPublishExecute_BuildlessSparkSync(t *testing.T) { - // Legacy .spark project without a declaration: buildless per protocol — - // no build runs, dist/output is packed as-is, online_url is backfilled. - root := chdirProjectRoot(t, `{"app_id":"app_x"}`) +func TestAppDevPublishExecute_BuildlessSync(t *testing.T) { + // spark.json without build.command: buildless — no build runs, + // dist/output is packed as-is, the app section gains the url. + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) f := &fakeEnvRunner{} withFakeEnvRunner(t, f) @@ -734,16 +662,17 @@ func TestAppDevPublishExecute_BuildlessSparkSync(t *testing.T) { if data["built"] != false { t.Errorf("built = %v, want false for buildless", data["built"]) } - b, _ := os.ReadFile(filepath.Join(root, metaRelPath)) - var meta map[string]interface{} - _ = json.Unmarshal(b, &meta) - if meta["online_url"] != "https://x/app/app_x" { - t.Errorf("meta after publish = %v", meta) + b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) + var doc map[string]interface{} + _ = json.Unmarshal(b, &doc) + app, _ := doc["app"].(map[string]interface{}) + if app == nil || app["url"] != "https://x/app/app_x" { + t.Errorf("app section after publish = %v", doc["app"]) } } func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { - root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -767,17 +696,17 @@ func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { if _, has := data["online_url"]; has { t.Error("async must not carry online_url") } - // No online_url -> no backfill. - b, _ := os.ReadFile(filepath.Join(root, metaRelPath)) - if strings.Contains(string(b), "online_url") { - t.Errorf("meta must not gain online_url on async publish: %s", b) + // No online_url -> the app section carries no url key. + b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) + if strings.Contains(string(b), "\"url\"") { + t.Errorf("spark.json must not gain app.url on a still-publishing release: %s", b) } } func TestAppDevPublishExecute_AwaitFinished(t *testing.T) { // Async acceptance followed by a finished poll: online_url comes back in - // one command and lands in miaoda.json's app section. - root := chdirMiaodaProjectRoot(t, `{"stack":"s","app":{"id":"app_x"}}`) + // one command and lands in spark.json's app section. + root := chdirSparkProjectRoot(t, `{"stack":"s","app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, appDevDefaultBuildOutput), []string{"index.html", "routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -798,7 +727,7 @@ func TestAppDevPublishExecute_AwaitFinished(t *testing.T) { if _, has := data["poll_hint"]; has { t.Error("finished await must not carry poll_hint") } - b, _ := os.ReadFile(filepath.Join(root, miaodaJSONRelPath)) + b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) var doc map[string]interface{} _ = json.Unmarshal(b, &doc) app, _ := doc["app"].(map[string]interface{}) @@ -810,7 +739,7 @@ func TestAppDevPublishExecute_AwaitFinished(t *testing.T) { func TestAppDevPublishExecute_AwaitFailed(t *testing.T) { // A failed pipeline is a failed publish: exit non-zero with the // error_logs summarized and an actionable hint. - root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -848,7 +777,7 @@ func TestSummarizeReleaseErrorLogs(t *testing.T) { } func TestAppDevPublishExecute_BuildFails(t *testing.T) { - chdirMiaodaProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) + chdirSparkProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) f := &fakeEnvRunner{stderr: "TS2304: boom", err: errors.New("exit 1")} withFakeEnvRunner(t, f) @@ -865,7 +794,7 @@ func TestAppDevPublishExecute_BuildFails(t *testing.T) { } func TestAppDevPublishExecute_PreReleaseMissingKVs(t *testing.T) { - root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -884,7 +813,7 @@ func TestAppDevPublishExecute_PreReleaseMissingKVs(t *testing.T) { } func TestAppDevPublishExecute_NonHTTPSUploadURL(t *testing.T) { - root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", "http://insecure.example/put", nil) @@ -896,7 +825,7 @@ func TestAppDevPublishExecute_NonHTTPSUploadURL(t *testing.T) { } func TestAppDevPublishExecute_TOS5xx(t *testing.T) { - root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -909,7 +838,7 @@ func TestAppDevPublishExecute_TOS5xx(t *testing.T) { } func TestAppDevPublishDryRun(t *testing.T) { - root := chdirMiaodaProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"],"output":"dist/output"}}`) + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"],"output":"dist/output"}}`) writeDistFiles(t, filepath.Join(root, "dist", "output"), []string{"index.html"}) factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout); err != nil { @@ -934,7 +863,7 @@ func TestAppDevPublishDryRun(t *testing.T) { } func TestAppDevPublishDryRun_Buildless(t *testing.T) { - root := chdirProjectRoot(t, `{"app_id":"app_x"}`) + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html"}) factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user", "--dry-run"}, factory, stdout); err != nil { @@ -963,9 +892,9 @@ func TestAppDevPublishDryRun_Buildless(t *testing.T) { } func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { - // miaoda.json declares a custom build command and output dir; the app + // spark.json declares a custom build command and output dir; the app // section is replaced wholesale on success. - root := chdirMiaodaProjectRoot(t, `{ + root := chdirSparkProjectRoot(t, `{ "stack": "custom-webapp", "build": { "command": ["make", "site"], "output": "public" }, "app": { "id": "app_x" } @@ -990,7 +919,7 @@ func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { t.Errorf("build call = %v %v, want make site", f.name, f.args) } // App section replaced wholesale with id+url; declarations preserved. - b, _ := os.ReadFile(filepath.Join(root, miaodaJSONRelPath)) + b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) var doc map[string]interface{} _ = json.Unmarshal(b, &doc) app, _ := doc["app"].(map[string]interface{}) @@ -1003,9 +932,9 @@ func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { } func TestAppDevPublishExecute_MiaodaFlagBackfill(t *testing.T) { - // No recorded app id in miaoda.json: --app-id publishes and the app + // No recorded app id in spark.json: --app-id publishes and the app // section is written on success (async: no url yet). - root := chdirMiaodaProjectRoot(t, `{"stack":"react-standard-webapp"}`) + root := chdirSparkProjectRoot(t, `{"stack":"react-standard-webapp"}`) writeDistFiles(t, filepath.Join(root, appDevDefaultBuildOutput), []string{"index.html", "routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -1015,7 +944,7 @@ func TestAppDevPublishExecute_MiaodaFlagBackfill(t *testing.T) { []string{"+deploy", "--app-id", "app_new1", "--skip-build", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } - b, _ := os.ReadFile(filepath.Join(root, miaodaJSONRelPath)) + b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) var doc map[string]interface{} _ = json.Unmarshal(b, &doc) app, _ := doc["app"].(map[string]interface{}) @@ -1028,12 +957,12 @@ func TestAppDevPublishExecute_MiaodaFlagBackfill(t *testing.T) { } func TestAppDevPublishValidate_MiaodaMismatch(t *testing.T) { - chdirMiaodaProjectRoot(t, `{"app": {"id": "app_recorded"}}`) + chdirSparkProjectRoot(t, `{"app": {"id": "app_recorded"}}`) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--app-id", "app_other", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) - if !strings.Contains(p.Message, "miaoda.json") || !strings.Contains(p.Message, "app_recorded") { + if !strings.Contains(p.Message, "spark.json") || !strings.Contains(p.Message, "app_recorded") { t.Errorf("message = %q", p.Message) } } diff --git a/shortcuts/apps/apps_init_template.go b/shortcuts/apps/apps_init_template.go index 4561611320..f688deebed 100644 --- a/shortcuts/apps/apps_init_template.go +++ b/shortcuts/apps/apps_init_template.go @@ -257,7 +257,7 @@ var AppsInitTemplate = common.Shortcut{ if err != nil { return err } - if err := writeMiaodaScaffoldFields(dir, template, version); err != nil { + if err := writeSparkScaffoldFields(dir, template, version); err != nil { return err } devPrefix := "" @@ -267,7 +267,7 @@ var AppsInitTemplate = common.Shortcut{ nextSteps := []string{ devPrefix + "npm install && npm run dev", "lark-cli apps +create --name to create the Miaoda app", - "run lark-cli apps +deploy --app-id from the project root (saved into miaoda.json on success; later runs need no flag)", + "run lark-cli apps +deploy --app-id from the project root (saved into spark.json on success; later runs need no flag)", } data := map[string]interface{}{ "dir": dir, diff --git a/shortcuts/apps/apps_init_template_test.go b/shortcuts/apps/apps_init_template_test.go index f05a1ab100..9e4c64892d 100644 --- a/shortcuts/apps/apps_init_template_test.go +++ b/shortcuts/apps/apps_init_template_test.go @@ -308,10 +308,10 @@ func TestRenderAppDevTemplate_FileCountCap(t *testing.T) { func TestWriteMiaodaScaffoldFields(t *testing.T) { dir := t.TempDir() // Fresh project: stack + version stamped. - if err := writeMiaodaScaffoldFields(dir, "react-standard-webapp", "1.2.3"); err != nil { + if err := writeSparkScaffoldFields(dir, "react-standard-webapp", "1.2.3"); err != nil { t.Fatal(err) } - b, err := os.ReadFile(filepath.Join(dir, miaodaJSONRelPath)) + b, err := os.ReadFile(filepath.Join(dir, sparkJSONRelPath)) if err != nil { t.Fatal(err) } @@ -325,13 +325,13 @@ func TestWriteMiaodaScaffoldFields(t *testing.T) { // Seed-shipped declarations are preserved; seed stack wins; version is // re-stamped with the rendered package version. seed := `{"stack":"seed-stack","version":"0.0.1","build":{"command":["make","dist"],"output":"out"},"dev":{"port":5173}}` - if err := os.WriteFile(filepath.Join(dir, miaodaJSONRelPath), []byte(seed), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, sparkJSONRelPath), []byte(seed), 0o644); err != nil { t.Fatal(err) } - if err := writeMiaodaScaffoldFields(dir, "react-standard-webapp", "2.0.0"); err != nil { + if err := writeSparkScaffoldFields(dir, "react-standard-webapp", "2.0.0"); err != nil { t.Fatal(err) } - b, _ = os.ReadFile(filepath.Join(dir, miaodaJSONRelPath)) + b, _ = os.ReadFile(filepath.Join(dir, sparkJSONRelPath)) doc = map[string]interface{}{} _ = json.Unmarshal(b, &doc) if doc["stack"] != "seed-stack" { @@ -653,15 +653,15 @@ func TestAppDevInitTemplateExecute_RendersFromRegistry(t *testing.T) { if err != nil || !strings.Contains(string(b), dir) { t.Errorf("index.html placeholder = %q err=%v (projectName is dir basename)", b, err) } - // miaoda.json written by lark-cli (protocol §3). - mb, err := os.ReadFile(filepath.Join(dir, miaodaJSONRelPath)) + // spark.json written by lark-cli (protocol §3). + mb, err := os.ReadFile(filepath.Join(dir, sparkJSONRelPath)) if err != nil { t.Fatal(err) } var doc map[string]interface{} _ = json.Unmarshal(mb, &doc) if doc["stack"] != "react-standard-webapp" || doc["version"] != "1.2.3" { - t.Errorf("miaoda.json = %v", doc) + t.Errorf("spark.json = %v", doc) } steps, _ := data["next_steps"].([]interface{}) if len(steps) != 3 { diff --git a/skills/lark-apps/references/lark-apps-deploy.md b/skills/lark-apps/references/lark-apps-deploy.md index 296974c089..56f455c055 100644 --- a/skills/lark-apps/references/lark-apps-deploy.md +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -8,31 +8,31 @@ ## 命令骨架 -- **必须在项目根目录执行**(项目根须有 `miaoda.json`;旧项目回退读 `.spark/meta.json`)。同源产物目录取 miaoda.json 的 `build.output`(缺省 `dist/output`),CDN 产物目录取可选的 `build.output_cdn`(不声明 = 无 CDN 分离),无 `--path` 参数。 -- `--app-id` 可选:首次发布传它指定目标(成功后自动写入 `miaoda.json` 的 app 段,后续免传);已记录 app id 时可省略;**两者都有且不一致会被拒绝**(防误发错目标),确要切换先更新 miaoda.json。 +- **必须在项目根目录执行**(项目根须有 `spark.json`,它是唯一的项目声明文件)。同源产物目录取 spark.json 的 `build.output`(缺省 `dist/output`),CDN 产物目录取可选的 `build.output_cdn`(不声明 = 无 CDN 分离),无 `--path` 参数。 +- `--app-id` 可选:首次发布传它指定目标(成功后自动写入 `spark.json` 的 app 段,后续免传);已记录 app id 时可省略;**两者都有且不一致会被拒绝**(防误发错目标),确要切换先更新 spark.json。 - 可选:`--skip-build`(跳过 `build.command`,直接发布已有产物目录)、`--allow-sensitive`(跳过凭据文件扫描)。 -- 内部流程:读 miaoda.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → 执行 `build.command`(argv 直接执行不走 shell,自动注入变量;**miaoda.json 未声明 build.command = buildless,跳过构建直接打包**)→ 校验产物协议 → 归一化打包(`build.output` → zip 内 `output/`,`build.output_cdn` → zip 内 `output_resource/`,流水线不感知项目目录名)→ 上传 → 触发发布。 +- 内部流程:读 spark.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → 执行 `build.command`(argv 直接执行不走 shell,自动注入变量;**spark.json 未声明 build.command = buildless,跳过构建直接打包**)→ 校验产物协议 → 归一化打包(`build.output` → zip 内 `output/`,`build.output_cdn` → zip 内 `output_resource/`,流水线不感知项目目录名)→ 上传 → 触发发布。 - 产物协议(详见《妙搭产物托管协议规范》):`build.output` 目录必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(**路由枚举数组**,如 `[{"path":"/","file":"index.html"}]`,纯静态站可为空数组;它是安全扫描的输入,必须与真实路由一致);目录内其余静态文件全部随包上传。**buildless 项目缺 routes.json 时由 CLI 扫描 `.html` 文件树自动生成**(`foo/index.html` → `/foo`),工程自带的 routes.json 永不被覆盖。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 ## 示例 ```bash -lark-cli apps +deploy --app-id app_xxx # 首次发布:指定目标,成功后写入 miaoda.json -lark-cli apps +deploy # 迭代重发:读 miaoda.json,零参数 +lark-cli apps +deploy --app-id app_xxx # 首次发布:指定目标,成功后写入 spark.json +lark-cli apps +deploy # 迭代重发:读 spark.json,零参数 lark-cli apps +deploy --skip-build lark-cli apps +deploy --dry-run ``` ## 输出契约 -- 异步受理后命令会**原地等待最多 60s**(每 3s 轮询发布单):等到 `finished` 则直接返回 `data.online_url`(并随 app 段回写进 `miaoda.json`),一条命令闭环。 +- 异步受理后命令会**原地等待最多 60s**(每 3s 轮询发布单):等到 `finished` 则直接返回 `data.online_url`(并随 app 段回写进 `spark.json`),一条命令闭环。 - 超过 60s 仍在发布中:返回 `data.release_id` 和 `data.poll_hint`(不算失败);用 `+release-get --app-id --release-id ` 继续轮询到 `finished` 后读取 `online_url`。 - **流水线失败 = 发布失败**:exit 非 0,message 含各 step 的 error_logs 摘要,hint 给出复查命令;产物已上传,修复后重新 publish 即可。 - 业务失败通常带 `error.hint`,优先转述 hint;网络/服务端 5xx 失败带 `retryable`,可稍后重试。 ## 前置引导 -- 未记录 app id 时:先 `lark-cli apps +create --name ` 创建应用,然后 `lark-cli apps +deploy --app-id <返回的 app_id>` 发布(成功后自动写入 miaoda.json,无需手工编辑文件);应用名可从项目主题生成,不要让用户手动提供 app_id。 +- 未记录 app id 时:先 `lark-cli apps +create --name ` 创建应用,然后 `lark-cli apps +deploy --app-id <返回的 app_id>` 发布(成功后自动写入 spark.json,无需手工编辑文件);应用名可从项目主题生成,不要让用户手动提供 app_id。 - **记录的 app id 不是本会话写入的**(来自历史文件或他人仓库)时,发布前先把目标 app id 告知用户并确认——发布会覆盖该应用的线上内容。 ## 安全规则 @@ -42,7 +42,7 @@ lark-cli apps +deploy --dry-run ## 常见失败 -- `current directory is not a Miaoda app project`:不在项目根执行;`cd` 到含 `miaoda.json` 的目录。 +- `current directory is not a Miaoda app project`:不在项目根执行;`cd` 到含 `spark.json` 的目录。 - `routes.json is missing` / schema 校验失败:声明了 `build.command` 的项目由构建脚本负责生成合法 routes.json;让用户检查构建配置,不要手工伪造(buildless 项目无此问题,CLI 会自动生成)。 -- `build command ... failed`:转述 stderr 摘要让用户修构建错误(构建命令来自 miaoda.json `build.command`);用户已手动构建时可用 `--skip-build`。 +- `build command ... failed`:转述 stderr 摘要让用户修构建错误(构建命令来自 spark.json `build.command`);用户已手动构建时可用 `--skip-build`。 - `artifact directory ... does not exist`:声明了构建命令时先构建(或去掉 `--skip-build`);buildless 项目需确认 `build.output` 指向的目录真实存在。 diff --git a/skills/lark-apps/references/lark-apps-init-template.md b/skills/lark-apps/references/lark-apps-init-template.md index 22cfcbf935..890b198b25 100644 --- a/skills/lark-apps/references/lark-apps-init-template.md +++ b/skills/lark-apps/references/lark-apps-init-template.md @@ -28,9 +28,9 @@ lark-cli apps +init-template --type full_stack --dry-run 返回 `data.dir`(项目目录)、`data.template`、`data.stack` 和 `data.next_steps`(后续步骤清单)。按 next_steps 引导用户: -1. `cd && npm install && npm run dev` 本地开发预览(dev 命令声明见项目根 `miaoda.json`); +1. `cd && npm install && npm run dev` 本地开发预览(dev 命令声明见项目根 `spark.json`); 2. 需要发布时先 `lark-cli apps +create --name ` 创建妙搭应用; -3. 在项目根运行 `lark-cli apps +deploy --app-id <返回的 app_id>` 构建并发布(成功后 app id 写入 miaoda.json,后续免传;见 [lark-apps-deploy.md](lark-apps-deploy.md))。 +3. 在项目根运行 `lark-cli apps +deploy --app-id <返回的 app_id>` 构建并发布(成功后 app id 写入 spark.json,后续免传;见 [lark-apps-deploy.md](lark-apps-deploy.md))。 ## 常见失败 From 24850a947d78a9911e9910e4f467d6b8aebb1bfa Mon Sep 17 00:00:00 2001 From: duanlikang Date: Fri, 28 Aug 2026 10:25:49 +0800 Subject: [PATCH 32/51] fix(apps): recover online_url when a release reports finished without it A finished create response lacking online_url now triggers one release-get to fetch the url instead of returning empty with a misleading 'still finished' note; poll_hint is also suppressed when there is no release_id to poll. --- shortcuts/apps/apps_deploy.go | 19 ++++++++++++++----- shortcuts/apps/apps_deploy_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index e9198af1c8..2cd296a669 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -355,7 +355,12 @@ func awaitAppDevRelease(ctx context.Context, rctx *common.RuntimeContext, appID, for i := 0; ; i++ { switch status { case "finished": - return status, onlineURL, nil + // A finished report without online_url on the first look (the + // create response may omit it) gets one release-get below to + // recover the url before returning. + if onlineURL != "" || i > 0 { + return status, onlineURL, nil + } case "failed": if errorLogs == nil { // The create call reported failed without details — fetch once. @@ -371,7 +376,7 @@ func awaitAppDevRelease(ctx context.Context, rctx *common.RuntimeContext, appID, "release %s failed: %s", releaseID, msg). WithHint(fmt.Sprintf("the artifact was uploaded but the deploy pipeline failed; inspect with `lark-cli apps +release-get --app-id %s --release-id %s`, fix the reported step, then publish again", appID, releaseID)) } - if !time.Now().Before(deadline) { + if status != "finished" && !time.Now().Before(deadline) { return status, "", nil } if i > 0 { @@ -614,7 +619,11 @@ var AppsDeploy = common.Shortcut{ } onlineURL = finalURL if onlineURL == "" { - fmt.Fprintf(rctx.IO().ErrOut, "release still %s; continue polling manually\n", status) + if status == "finished" { + fmt.Fprintf(rctx.IO().ErrOut, "release finished but no online_url was returned; inspect it with `lark-cli apps +release-get`\n") + } else { + fmt.Fprintf(rctx.IO().ErrOut, "release still %s; continue polling manually\n", status) + } } } data := map[string]interface{}{ @@ -628,7 +637,7 @@ var AppsDeploy = common.Shortcut{ pollHint := "" if onlineURL != "" { data["online_url"] = onlineURL - } else { + } else if releaseID != "" { pollHint = fmt.Sprintf("lark-cli apps +release-get --app-id %s --release-id %s", appID, releaseID) data["poll_hint"] = pollHint } @@ -642,7 +651,7 @@ var AppsDeploy = common.Shortcut{ fmt.Fprintf(w, "app_id: %s\nrelease_id: %s\nstatus: %s\n", appID, releaseID, status) if onlineURL != "" { fmt.Fprintf(w, "online_url: %s\n", onlineURL) - } else { + } else if pollHint != "" { fmt.Fprintf(w, "async release; poll with: %s\n", pollHint) } }) diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index 535b2090f9..b46f0ad960 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -736,6 +736,33 @@ func TestAppDevPublishExecute_AwaitFinished(t *testing.T) { } } +func TestAppDevPublishExecute_FinishedWithoutURL(t *testing.T) { + // The create response may report finished without online_url; the + // command must fetch the release once to recover the url instead of + // returning an empty one with a misleading poll hint. + root := chdirSparkProjectRoot(t, `{"stack":"s","app":{"id":"app_x"}}`) + writeDistFiles(t, filepath.Join(root, appDevDefaultBuildOutput), []string{"index.html", "routes.json"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_51", "status": "finished"}) + stubReleaseGet(reg, "app_x", "rel_51", map[string]interface{}{ + "release_id": "rel_51", "status": "finished", + "online_url": "https://x/app/app_x", + }) + withFastAppDevPoll(t, time.Second, time.Millisecond) + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["online_url"] != "https://x/app/app_x" { + t.Errorf("online_url must be recovered via release-get, got %v", data["online_url"]) + } + if _, has := data["poll_hint"]; has { + t.Error("recovered finish must not carry poll_hint") + } +} + func TestAppDevPublishExecute_AwaitFailed(t *testing.T) { // A failed pipeline is a failed publish: exit non-zero with the // error_logs summarized and an actionable hint. From 02006902d96e6800792ce08993e8efb0486630e6 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Fri, 28 Aug 2026 14:36:20 +0800 Subject: [PATCH 33/51] feat(apps): return immediately after release acceptance in +deploy Agent runtimes cap foreground waits (~15s), so the 60s in-place polling only added latency: +deploy now hands back release_id + poll_hint as soon as the release is accepted, leaving polling to the caller via +release-get. Terminal create-responses are still resolved: a finished response missing online_url gets one recovery fetch, and a failed response surfaces the error_logs as a non-zero exit. --- shortcuts/apps/apps_deploy.go | 109 ++++++------------ shortcuts/apps/apps_deploy_test.go | 59 +--------- .../lark-apps/references/lark-apps-deploy.md | 4 +- 3 files changed, 46 insertions(+), 126 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index 2cd296a669..3c0cc02fc3 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -18,7 +18,6 @@ import ( "path/filepath" "sort" "strings" - "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" @@ -306,15 +305,6 @@ var appDevRunner envCommandRunner = execEnvCommandRunner{} // (the command only accepts https upload URLs). var appDevNewTransferClient = newFileTransferClient -// Bounded wait for an async release to finish. The html pipeline typically -// completes within seconds; past the timeout the command degrades to the -// release_id + poll hint output instead of failing. Vars so unit tests can -// shrink them. -var ( - appDevReleaseWaitTimeout = 60 * time.Second - appDevReleasePollInterval = 3 * time.Second -) - // summarizeReleaseErrorLogs flattens a release's error_logs (slice of // {step, error_log} objects) into one line for the failure message. func summarizeReleaseErrorLogs(v interface{}) string { @@ -343,58 +333,36 @@ func summarizeReleaseErrorLogs(v interface{}) string { return out } -// awaitAppDevRelease polls the release until it reaches a terminal state or -// the bounded wait elapses. finished returns the online_url; failed returns -// a structured error carrying the pipeline error_logs; a timeout or a poll -// request failure degrades gracefully — the release was accepted, so the -// caller falls back to the release_id + poll hint output. -func awaitAppDevRelease(ctx context.Context, rctx *common.RuntimeContext, appID, releaseID, status string) (finalStatus, onlineURL string, err error) { +// resolveAppDevReleaseOutcome handles a terminal create-response without +// blocking on an in-flight release (agent runtimes cannot sit in a long +// foreground wait; polling is the caller's job via +release-get): +// - finished without online_url: fetch the release once to recover the url +// - failed: fetch the error_logs once and surface a structured error +// - anything else: return as-is — the caller gets release_id + poll hint +func resolveAppDevReleaseOutcome(ctx context.Context, rctx *common.RuntimeContext, appID, releaseID, status string) (finalStatus, onlineURL string, err error) { path := fmt.Sprintf(releaseGetPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(releaseID)) - deadline := time.Now().Add(appDevReleaseWaitTimeout) - var errorLogs interface{} - for i := 0; ; i++ { - switch status { - case "finished": - // A finished report without online_url on the first look (the - // create response may omit it) gets one release-get below to - // recover the url before returning. - if onlineURL != "" || i > 0 { - return status, onlineURL, nil - } - case "failed": - if errorLogs == nil { - // The create call reported failed without details — fetch once. - if data, gerr := rctx.CallAPITyped("GET", path, nil, nil); gerr == nil { - errorLogs = data["error_logs"] - } - } - msg := summarizeReleaseErrorLogs(errorLogs) - if msg == "" { - msg = "no error_logs reported" - } - return status, "", errs.NewInternalError(errs.SubtypeExternalTool, - "release %s failed: %s", releaseID, msg). - WithHint(fmt.Sprintf("the artifact was uploaded but the deploy pipeline failed; inspect with `lark-cli apps +release-get --app-id %s --release-id %s`, fix the reported step, then publish again", appID, releaseID)) - } - if status != "finished" && !time.Now().Before(deadline) { - return status, "", nil - } - if i > 0 { - select { - case <-ctx.Done(): - return status, "", nil - case <-time.After(appDevReleasePollInterval): - } - } - data, gerr := rctx.CallAPITyped("GET", path, nil, nil) - if gerr != nil { - // The release was accepted; a flaky poll must not fail the - // publish — degrade to the poll-hint output. - return status, "", nil //nolint:nilerr // deliberate degradation, see above. - } - status = common.GetString(data, "status") - onlineURL = common.GetString(data, "online_url") - errorLogs = data["error_logs"] + switch status { + case "finished": + // The create response may omit online_url — recover it with one + // release-get; a flaky fetch degrades to the poll-hint output. + if data, gerr := rctx.CallAPITyped("GET", path, nil, nil); gerr == nil { + return status, common.GetString(data, "online_url"), nil + } + return status, "", nil + case "failed": + var errorLogs interface{} + if data, gerr := rctx.CallAPITyped("GET", path, nil, nil); gerr == nil { + errorLogs = data["error_logs"] + } + msg := summarizeReleaseErrorLogs(errorLogs) + if msg == "" { + msg = "no error_logs reported" + } + return status, "", errs.NewInternalError(errs.SubtypeExternalTool, + "release %s failed: %s", releaseID, msg). + WithHint(fmt.Sprintf("the artifact was uploaded but the deploy pipeline failed; inspect with `lark-cli apps +release-get --app-id %s --release-id %s`, fix the reported step, then publish again", appID, releaseID)) + default: + return status, "", nil } } @@ -468,7 +436,7 @@ var AppsDeploy = common.Shortcut{ }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { dry := common.NewDryRunAPI(). - Desc("Resolve app id (spark.json / --app-id) -> GET pre_release (presigned upload URL + MIAODA_* build env) -> run build.command -> validate output layout -> zip -> PUT to TOS -> POST releases -> wait up to 60s for the async release; returns online_url, or release_id + poll hint when still publishing") + Desc("Resolve app id (spark.json / --app-id) -> GET pre_release (presigned upload URL + MIAODA_* build env) -> run build.command -> validate output layout -> zip -> PUT to TOS -> POST releases; returns online_url when the release finishes synchronously, or release_id + poll hint while it is still publishing") cfg, appID, fromFlag, err := resolveAppDevPublishTarget(rctx) if cfg == nil { cfg = &appDevProjectConfig{} @@ -602,15 +570,14 @@ var AppsDeploy = common.Shortcut{ releaseID := common.GetString(releaseData, "release_id") status := common.GetString(releaseData, "status") onlineURL := common.GetString(releaseData, "online_url") - // Async acceptance: wait briefly for the terminal state so the common - // case hands back online_url in one command (and app.url is written); - // past the bound, degrade to the poll-hint output. A failed pipeline - // is a failed publish — surfaced as an error, not a status field. + // The command returns as soon as the release is accepted — agent + // runtimes cannot sit in a long foreground wait, so polling an + // in-flight release is the caller's job (+release-get, see poll_hint). + // Terminal create-responses are still resolved: a failed pipeline is a + // failed publish, and a finished response missing online_url gets one + // recovery fetch. if onlineURL == "" && releaseID != "" { - if status != "finished" && status != "failed" { - fmt.Fprintf(rctx.IO().ErrOut, "release %s accepted (status %s); waiting up to %s for completion...\n", releaseID, status, appDevReleaseWaitTimeout) - } - finalStatus, finalURL, werr := awaitAppDevRelease(ctx, rctx, appID, releaseID, status) + finalStatus, finalURL, werr := resolveAppDevReleaseOutcome(ctx, rctx, appID, releaseID, status) if werr != nil { return werr } @@ -622,7 +589,7 @@ var AppsDeploy = common.Shortcut{ if status == "finished" { fmt.Fprintf(rctx.IO().ErrOut, "release finished but no online_url was returned; inspect it with `lark-cli apps +release-get`\n") } else { - fmt.Fprintf(rctx.IO().ErrOut, "release still %s; continue polling manually\n", status) + fmt.Fprintf(rctx.IO().ErrOut, "release %s accepted (status %s); poll with `lark-cli apps +release-get`\n", releaseID, status) } } } diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index b46f0ad960..b298c0f0f6 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -14,7 +14,6 @@ import ( "reflect" "strings" "testing" - "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/httpmock" @@ -417,15 +416,6 @@ func stubPreRelease(reg *httpmock.Registry, appID, uploadURL string, extraKVs ma }) } -// withFastAppDevPoll shrinks the async-release wait knobs so tests never -// sleep for real. -func withFastAppDevPoll(t *testing.T, timeout, interval time.Duration) { - t.Helper() - origT, origI := appDevReleaseWaitTimeout, appDevReleasePollInterval - appDevReleaseWaitTimeout, appDevReleasePollInterval = timeout, interval - t.Cleanup(func() { appDevReleaseWaitTimeout, appDevReleasePollInterval = origT, origI }) -} - func stubReleaseGet(reg *httpmock.Registry, appID, releaseID string, respData map[string]interface{}) { reg.Register(&httpmock.Stub{ Method: "GET", @@ -678,10 +668,8 @@ func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", srv.URL, nil) stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_2", "status": "pending"}) - // The bounded wait keeps polling "pending" until the (shrunk) timeout, - // then degrades to the poll-hint output. - stubReleaseGet(reg, "app_x", "rel_2", map[string]interface{}{"release_id": "rel_2", "status": "pending"}) - withFastAppDevPoll(t, 20*time.Millisecond, time.Millisecond) + // An in-flight release returns immediately with the poll hint — the + // command never blocks on polling (agent runtimes own the wait). if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } @@ -703,39 +691,6 @@ func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { } } -func TestAppDevPublishExecute_AwaitFinished(t *testing.T) { - // Async acceptance followed by a finished poll: online_url comes back in - // one command and lands in spark.json's app section. - root := chdirSparkProjectRoot(t, `{"stack":"s","app":{"id":"app_x"}}`) - writeDistFiles(t, filepath.Join(root, appDevDefaultBuildOutput), []string{"index.html", "routes.json"}) - srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) - factory, stdout, reg := newAppsExecuteFactory(t) - stubPreRelease(reg, "app_x", srv.URL, nil) - stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_40", "status": "publishing"}) - stubReleaseGet(reg, "app_x", "rel_40", map[string]interface{}{ - "release_id": "rel_40", "status": "finished", - "online_url": "https://x/app/app_x", - }) - withFastAppDevPoll(t, time.Second, time.Millisecond) - if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout); err != nil { - t.Fatalf("unexpected: %v", err) - } - data := parseEnvelopeData(t, stdout) - if data["online_url"] != "https://x/app/app_x" || data["status"] != "finished" { - t.Errorf("data = %v", data) - } - if _, has := data["poll_hint"]; has { - t.Error("finished await must not carry poll_hint") - } - b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) - var doc map[string]interface{} - _ = json.Unmarshal(b, &doc) - app, _ := doc["app"].(map[string]interface{}) - if app == nil || app["url"] != "https://x/app/app_x" { - t.Errorf("app.url must be written after the awaited finish, got %v", doc["app"]) - } -} - func TestAppDevPublishExecute_FinishedWithoutURL(t *testing.T) { // The create response may report finished without online_url; the // command must fetch the release once to recover the url instead of @@ -750,7 +705,6 @@ func TestAppDevPublishExecute_FinishedWithoutURL(t *testing.T) { "release_id": "rel_51", "status": "finished", "online_url": "https://x/app/app_x", }) - withFastAppDevPoll(t, time.Second, time.Millisecond) if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) } @@ -763,22 +717,21 @@ func TestAppDevPublishExecute_FinishedWithoutURL(t *testing.T) { } } -func TestAppDevPublishExecute_AwaitFailed(t *testing.T) { - // A failed pipeline is a failed publish: exit non-zero with the - // error_logs summarized and an actionable hint. +func TestAppDevPublishExecute_CreateReportsFailed(t *testing.T) { + // A create response that already reports failed is a failed publish: + // exit non-zero with the error_logs (fetched once) summarized. root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", srv.URL, nil) - stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_41", "status": "publishing"}) + stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_41", "status": "failed"}) stubReleaseGet(reg, "app_x", "rel_41", map[string]interface{}{ "release_id": "rel_41", "status": "failed", "error_logs": []interface{}{ map[string]interface{}{"step": "build", "error_log": "formula output is empty"}, }, }) - withFastAppDevPoll(t, time.Second, time.Millisecond) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryInternal) if !strings.Contains(p.Message, "release rel_41 failed") || !strings.Contains(p.Message, "[build] formula output is empty") { diff --git a/skills/lark-apps/references/lark-apps-deploy.md b/skills/lark-apps/references/lark-apps-deploy.md index 56f455c055..8968061b8a 100644 --- a/skills/lark-apps/references/lark-apps-deploy.md +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -25,8 +25,8 @@ lark-cli apps +deploy --dry-run ## 输出契约 -- 异步受理后命令会**原地等待最多 60s**(每 3s 轮询发布单):等到 `finished` 则直接返回 `data.online_url`(并随 app 段回写进 `spark.json`),一条命令闭环。 -- 超过 60s 仍在发布中:返回 `data.release_id` 和 `data.poll_hint`(不算失败);用 `+release-get --app-id --release-id ` 继续轮询到 `finished` 后读取 `online_url`。 +- 发布单受理后**命令立即返回,不原地等待**(agent 运行时不允许长前台等待,轮询由调用方负责):发布中时返回 `data.release_id` 和 `data.poll_hint`,用 `+release-get --app-id --release-id ` 轮询到 `finished` 后读取 `online_url`(轮询间隔 ≥3s)。 +- 同步完成(受理响应即 `finished`)时直接返回 `data.online_url` 并随 app 段回写进 `spark.json`。 - **流水线失败 = 发布失败**:exit 非 0,message 含各 step 的 error_logs 摘要,hint 给出复查命令;产物已上传,修复后重新 publish 即可。 - 业务失败通常带 `error.hint`,优先转述 hint;网络/服务端 5xx 失败带 `retryable`,可稍后重试。 From b275d0e0697fac9877065a3179162fc1c3ed3147 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Fri, 28 Aug 2026 15:44:46 +0800 Subject: [PATCH 34/51] refactor(apps): align local-dev helper filenames with the apps_ convention app_dev_project_config/app_dev_template_fetch/app_dev_publish_zip carried the retired command-cluster prefix; rename to apps_spark_config/apps_template_fetch/apps_deploy_zip to match the command files, and refresh a stale command name in the count test. --- shortcuts/apps/{app_dev_publish_zip.go => apps_deploy_zip.go} | 0 .../{app_dev_publish_zip_test.go => apps_deploy_zip_test.go} | 0 .../apps/{app_dev_project_config.go => apps_spark_config.go} | 0 .../apps/{app_dev_template_fetch.go => apps_template_fetch.go} | 0 shortcuts/apps/shortcuts_test.go | 2 +- 5 files changed, 1 insertion(+), 1 deletion(-) rename shortcuts/apps/{app_dev_publish_zip.go => apps_deploy_zip.go} (100%) rename shortcuts/apps/{app_dev_publish_zip_test.go => apps_deploy_zip_test.go} (100%) rename shortcuts/apps/{app_dev_project_config.go => apps_spark_config.go} (100%) rename shortcuts/apps/{app_dev_template_fetch.go => apps_template_fetch.go} (100%) diff --git a/shortcuts/apps/app_dev_publish_zip.go b/shortcuts/apps/apps_deploy_zip.go similarity index 100% rename from shortcuts/apps/app_dev_publish_zip.go rename to shortcuts/apps/apps_deploy_zip.go diff --git a/shortcuts/apps/app_dev_publish_zip_test.go b/shortcuts/apps/apps_deploy_zip_test.go similarity index 100% rename from shortcuts/apps/app_dev_publish_zip_test.go rename to shortcuts/apps/apps_deploy_zip_test.go diff --git a/shortcuts/apps/app_dev_project_config.go b/shortcuts/apps/apps_spark_config.go similarity index 100% rename from shortcuts/apps/app_dev_project_config.go rename to shortcuts/apps/apps_spark_config.go diff --git a/shortcuts/apps/app_dev_template_fetch.go b/shortcuts/apps/apps_template_fetch.go similarity index 100% rename from shortcuts/apps/app_dev_template_fetch.go rename to shortcuts/apps/apps_template_fetch.go diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index cf366ce07a..91f5ab5df7 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -10,7 +10,7 @@ import ( ) // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 -// 6 基础 + 1 init + 3 publish + 1 env-pull + 2 app-dev(init-app/publish) +// 6 基础 + 1 init + 3 publish + 1 env-pull + 2 本地开发(init-template/deploy) // - 6 observability(log-list/log-get/trace-list/trace-get/metric-list/analytics-list) // - 3 env(list/set/delete) // - 23 db(table-list/table-schema/sql/dev-init/data-import/data-export/sync create/list/get/enable/disable/update/delete/changelog-list/ From d7a97c06d8ccc2d8c43047f2b8b25fc28672d342 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Fri, 28 Aug 2026 16:06:53 +0800 Subject: [PATCH 35/51] feat(apps): sync app.url into spark.json when +release-get observes a finish +deploy returns on acceptance, so nothing wrote app.url back for async releases. The poll step is the deploy chain's last leg: when it sees status=finished with an online_url and the working directory's spark.json records exactly that app, it merge-writes app.url (best-effort; no spark.json, a mismatched id, or an already-synced url skip silently). --- shortcuts/apps/apps_release_get.go | 8 +++ shortcuts/apps/apps_release_get_test.go | 57 +++++++++++++++++++ shortcuts/apps/apps_spark_config.go | 22 +++++++ .../lark-apps/references/lark-apps-deploy.md | 1 + 4 files changed, 88 insertions(+) diff --git a/shortcuts/apps/apps_release_get.go b/shortcuts/apps/apps_release_get.go index c0dfa79b54..9f19e1079c 100644 --- a/shortcuts/apps/apps_release_get.go +++ b/shortcuts/apps/apps_release_get.go @@ -65,6 +65,14 @@ var AppsReleaseGet = common.Shortcut{ out["error_logs"] = el } } + // This poll is the async deploy chain's last step (+deploy returns on + // acceptance), so a finished release syncs the app state section of a + // matching project's spark.json. Skips silently in every other setting. + if status, _ := out["status"].(string); status == "finished" { + if url, _ := out["online_url"].(string); url != "" { + syncSparkAppURL(rctx, appID, url) + } + } rctx.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n", out["release_id"], out["status"], out["created_at"], out["updated_at"]) diff --git a/shortcuts/apps/apps_release_get_test.go b/shortcuts/apps/apps_release_get_test.go index 9cd46cb38f..31db1cb344 100644 --- a/shortcuts/apps/apps_release_get_test.go +++ b/shortcuts/apps/apps_release_get_test.go @@ -7,6 +7,8 @@ import ( "bytes" "context" "encoding/json" + "os" + "path/filepath" "strings" "testing" @@ -56,6 +58,61 @@ func newStatusRuntimeContext(t *testing.T, appID, releaseID string) (*common.Run return rctx, stdoutBuf, reg } +func TestAppsReleaseGet_SyncsSparkAppURL(t *testing.T) { + // A finished poll observed from the app's own project root writes the + // url into spark.json's app section (the deploy chain owns that state, + // and +deploy returns before an async release finishes). + root := chdirSparkProjectRoot(t, `{"stack":"s","app":{"id":"app_x"}}`) + rctx, _, reg := newStatusRuntimeContext(t, "app_x", "7") + stubReleaseGet(reg, "app_x", "7", map[string]interface{}{ + "release_id": "7", "status": "finished", + "online_url": "https://x/app/app_x", + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("unexpected: %v", err) + } + b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) + var doc map[string]interface{} + _ = json.Unmarshal(b, &doc) + app, _ := doc["app"].(map[string]interface{}) + if app == nil || app["url"] != "https://x/app/app_x" { + t.Errorf("app.url must be synced, got %v", doc["app"]) + } +} + +func TestAppsReleaseGet_NoSparkJSONSkipsSync(t *testing.T) { + // No spark.json in the working directory (e.g. polling an html app's + // release): the sync is skipped silently and nothing is created. + root := chdirSparkProjectRoot(t, "") + rctx, _, reg := newStatusRuntimeContext(t, "app_x", "8") + stubReleaseGet(reg, "app_x", "8", map[string]interface{}{ + "release_id": "8", "status": "finished", + "online_url": "https://x/app/app_x", + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("unexpected: %v", err) + } + if _, err := os.Stat(filepath.Join(root, sparkJSONRelPath)); !os.IsNotExist(err) { + t.Error("sync must not create a spark.json where none exists") + } +} + +func TestAppsReleaseGet_MismatchedAppIDSkipsSync(t *testing.T) { + root := chdirSparkProjectRoot(t, `{"app":{"id":"app_other"}}`) + rctx, _, reg := newStatusRuntimeContext(t, "app_x", "9") + stubReleaseGet(reg, "app_x", "9", map[string]interface{}{ + "release_id": "9", "status": "finished", + "online_url": "https://x/app/app_x", + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("unexpected: %v", err) + } + b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) + if strings.Contains(string(b), "https://x/app/app_x") { + t.Errorf("mismatched app id must not be synced: %s", b) + } +} + func TestAppsReleaseGetExecute_Success(t *testing.T) { rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5") reg.Register(&httpmock.Stub{ diff --git a/shortcuts/apps/apps_spark_config.go b/shortcuts/apps/apps_spark_config.go index 31b73c3b5e..b9bf1ff9c1 100644 --- a/shortcuts/apps/apps_spark_config.go +++ b/shortcuts/apps/apps_spark_config.go @@ -5,9 +5,12 @@ package apps import ( "encoding/json" + "fmt" "os" "path/filepath" "strings" + + "github.com/larksuite/cli/shortcuts/common" ) // sparkJSONRelPath is the project declaration file of the artifact-hosting @@ -128,6 +131,25 @@ func writeSparkAppSection(dir, appID, appURL string) error { return nil } +// syncSparkAppURL writes online_url into the cwd's spark.json app section +// when — and only when — it records exactly this app. The deploy chain owns +// the app state section, and +deploy returns before an async release +// finishes, so the poll step is the first to learn the final url. Narrowly +// scoped and best-effort: no spark.json in the working directory, a +// different recorded app id, or an already-synced url all skip silently; a +// write failure only warns on stderr. +func syncSparkAppURL(rctx *common.RuntimeContext, appID, onlineURL string) { + cfg, found, err := readAppDevProjectConfig(".") + if err != nil || !found || cfg.AppID != appID || cfg.AppURL == onlineURL { + return + } + if werr := writeSparkAppSection(".", appID, onlineURL); werr != nil { + fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to sync app.url into %s: %v\n", sparkJSONRelPath, werr) + return + } + fmt.Fprintf(rctx.IO().ErrOut, "app.url synced into %s\n", sparkJSONRelPath) +} + // writeSparkScaffoldFields merge-writes the scaffold-owned fields into // /spark.json after template rendering: version is always stamped with // the rendered package version (authoritative), stack is only filled when the diff --git a/skills/lark-apps/references/lark-apps-deploy.md b/skills/lark-apps/references/lark-apps-deploy.md index 8968061b8a..889fb483ae 100644 --- a/skills/lark-apps/references/lark-apps-deploy.md +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -26,6 +26,7 @@ lark-cli apps +deploy --dry-run ## 输出契约 - 发布单受理后**命令立即返回,不原地等待**(agent 运行时不允许长前台等待,轮询由调用方负责):发布中时返回 `data.release_id` 和 `data.poll_hint`,用 `+release-get --app-id --release-id ` 轮询到 `finished` 后读取 `online_url`(轮询间隔 ≥3s)。 +- **在项目根轮询**:`+release-get` 观察到 `finished` 且当前目录 spark.json 记录的正是该 app 时,会自动把 `online_url` 回写进 app 段(无 spark.json 或 id 不匹配时静默跳过)——所以轮询尽量在项目根执行,让状态区保持最新。 - 同步完成(受理响应即 `finished`)时直接返回 `data.online_url` 并随 app 段回写进 `spark.json`。 - **流水线失败 = 发布失败**:exit 非 0,message 含各 step 的 error_logs 摘要,hint 给出复查命令;产物已上传,修复后重新 publish 即可。 - 业务失败通常带 `error.hint`,优先转述 hint;网络/服务端 5xx 失败带 `retryable`,可稍后重试。 From 42544f38f0e15223bec905104897910e985603a1 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Fri, 28 Aug 2026 16:11:00 +0800 Subject: [PATCH 36/51] feat(apps): rename the spark.json state field app.url to app.online_url Aligns the declaration file's state section with the online_url name used by the releases API, the CLI output, and the legacy meta file. --- shortcuts/apps/apps_deploy_test.go | 12 ++++++------ shortcuts/apps/apps_release_get_test.go | 4 ++-- shortcuts/apps/apps_spark_config.go | 12 ++++++------ skills/lark-apps/references/lark-apps-deploy.md | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index b298c0f0f6..52c85ec782 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -623,7 +623,7 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { var doc map[string]interface{} _ = json.Unmarshal(b, &doc) app, _ := doc["app"].(map[string]interface{}) - if app == nil || app["id"] != "app_x" || app["url"] != "https://x.feishuapp.cn/app/app_x" { + if app == nil || app["id"] != "app_x" || app["online_url"] != "https://x.feishuapp.cn/app/app_x" { t.Errorf("app section after publish = %v", doc["app"]) } } @@ -656,7 +656,7 @@ func TestAppDevPublishExecute_BuildlessSync(t *testing.T) { var doc map[string]interface{} _ = json.Unmarshal(b, &doc) app, _ := doc["app"].(map[string]interface{}) - if app == nil || app["url"] != "https://x/app/app_x" { + if app == nil || app["online_url"] != "https://x/app/app_x" { t.Errorf("app section after publish = %v", doc["app"]) } } @@ -686,8 +686,8 @@ func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { } // No online_url -> the app section carries no url key. b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) - if strings.Contains(string(b), "\"url\"") { - t.Errorf("spark.json must not gain app.url on a still-publishing release: %s", b) + if strings.Contains(string(b), "\"online_url\"") { + t.Errorf("spark.json must not gain app.online_url on a still-publishing release: %s", b) } } @@ -903,7 +903,7 @@ func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { var doc map[string]interface{} _ = json.Unmarshal(b, &doc) app, _ := doc["app"].(map[string]interface{}) - if app == nil || app["id"] != "app_x" || app["url"] != "https://x/app/app_x" { + if app == nil || app["id"] != "app_x" || app["online_url"] != "https://x/app/app_x" { t.Errorf("app section = %v", doc["app"]) } if doc["stack"] != "custom-webapp" || doc["build"] == nil { @@ -931,7 +931,7 @@ func TestAppDevPublishExecute_MiaodaFlagBackfill(t *testing.T) { if app == nil || app["id"] != "app_new1" { t.Errorf("app section = %v", doc["app"]) } - if _, has := app["url"]; has { + if _, has := app["online_url"]; has { t.Error("async publish must not write app.url") } } diff --git a/shortcuts/apps/apps_release_get_test.go b/shortcuts/apps/apps_release_get_test.go index 31db1cb344..0035d8c91e 100644 --- a/shortcuts/apps/apps_release_get_test.go +++ b/shortcuts/apps/apps_release_get_test.go @@ -75,8 +75,8 @@ func TestAppsReleaseGet_SyncsSparkAppURL(t *testing.T) { var doc map[string]interface{} _ = json.Unmarshal(b, &doc) app, _ := doc["app"].(map[string]interface{}) - if app == nil || app["url"] != "https://x/app/app_x" { - t.Errorf("app.url must be synced, got %v", doc["app"]) + if app == nil || app["online_url"] != "https://x/app/app_x" { + t.Errorf("app.online_url must be synced, got %v", doc["app"]) } } diff --git a/shortcuts/apps/apps_spark_config.go b/shortcuts/apps/apps_spark_config.go index b9bf1ff9c1..9858f30190 100644 --- a/shortcuts/apps/apps_spark_config.go +++ b/shortcuts/apps/apps_spark_config.go @@ -56,7 +56,7 @@ type sparkJSONDoc struct { } `json:"build"` App struct { ID string `json:"id"` - URL string `json:"url"` + URL string `json:"online_url"` } `json:"app"` } @@ -103,9 +103,9 @@ func applyAppDevConfigDefaults(cfg *appDevProjectConfig) { func (c *appDevProjectConfig) Buildless() bool { return len(c.BuildCommand) == 0 } // writeSparkAppSection replaces the app state section of /spark.json -// with {id, url} after a successful publish (§3: the app section is owned by +// with {id, online_url} after a successful publish (§3: the app section is owned by // the deploy chain and replaced wholesale; declaration fields are never -// touched). Empty url omits the key. Creates the file if missing. +// touched). Empty online_url omits the key. Creates the file if missing. func writeSparkAppSection(dir, appID, appURL string) error { path := filepath.Join(dir, sparkJSONRelPath) doc := map[string]interface{}{} @@ -118,7 +118,7 @@ func writeSparkAppSection(dir, appID, appURL string) error { } app := map[string]interface{}{"id": appID} if appURL != "" { - app["url"] = appURL + app["online_url"] = appURL } doc["app"] = app out, err := json.MarshalIndent(doc, "", " ") @@ -144,10 +144,10 @@ func syncSparkAppURL(rctx *common.RuntimeContext, appID, onlineURL string) { return } if werr := writeSparkAppSection(".", appID, onlineURL); werr != nil { - fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to sync app.url into %s: %v\n", sparkJSONRelPath, werr) + fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to sync app.online_url into %s: %v\n", sparkJSONRelPath, werr) return } - fmt.Fprintf(rctx.IO().ErrOut, "app.url synced into %s\n", sparkJSONRelPath) + fmt.Fprintf(rctx.IO().ErrOut, "app.online_url synced into %s\n", sparkJSONRelPath) } // writeSparkScaffoldFields merge-writes the scaffold-owned fields into diff --git a/skills/lark-apps/references/lark-apps-deploy.md b/skills/lark-apps/references/lark-apps-deploy.md index 889fb483ae..08106892a1 100644 --- a/skills/lark-apps/references/lark-apps-deploy.md +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -26,7 +26,7 @@ lark-cli apps +deploy --dry-run ## 输出契约 - 发布单受理后**命令立即返回,不原地等待**(agent 运行时不允许长前台等待,轮询由调用方负责):发布中时返回 `data.release_id` 和 `data.poll_hint`,用 `+release-get --app-id --release-id ` 轮询到 `finished` 后读取 `online_url`(轮询间隔 ≥3s)。 -- **在项目根轮询**:`+release-get` 观察到 `finished` 且当前目录 spark.json 记录的正是该 app 时,会自动把 `online_url` 回写进 app 段(无 spark.json 或 id 不匹配时静默跳过)——所以轮询尽量在项目根执行,让状态区保持最新。 +- **在项目根轮询**:`+release-get` 观察到 `finished` 且当前目录 spark.json 记录的正是该 app 时,会自动把 `online_url` 回写进 app 段(`app.online_url`)(无 spark.json 或 id 不匹配时静默跳过)——所以轮询尽量在项目根执行,让状态区保持最新。 - 同步完成(受理响应即 `finished`)时直接返回 `data.online_url` 并随 app 段回写进 `spark.json`。 - **流水线失败 = 发布失败**:exit 非 0,message 含各 step 的 error_logs 摘要,hint 给出复查命令;产物已上传,修复后重新 publish 即可。 - 业务失败通常带 `error.hint`,优先转述 hint;网络/服务端 5xx 失败带 `retryable`,可稍后重试。 From 9903285a6fc87eb7db4487690f2e3e227a8e86d1 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Sat, 29 Aug 2026 14:35:31 +0800 Subject: [PATCH 37/51] feat(apps): enforce declaration MUSTs at the +deploy hosting entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stack is now required with a supported hosting-shape suffix (-webapp/-fullstack) and dev.port is required (1-65535) — after hosting, platform capabilities rely on the project's local self-description endpoint (GET localhost:/spark.json). A payload missing index.html warns loudly but does not block, per the protocol decision (the gateway SPA fallback depends on it). --- shortcuts/apps/apps_deploy.go | 49 ++++++++++++ shortcuts/apps/apps_deploy_test.go | 75 +++++++++++++++---- shortcuts/apps/apps_spark_config.go | 16 +++- .../lark-apps/references/lark-apps-deploy.md | 3 + 4 files changed, 123 insertions(+), 20 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index 3c0cc02fc3..d46029e008 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -229,6 +229,46 @@ func appDevSensitiveCandidatesError(hits []string) error { WithHint("remove these files from the artifact directories, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)") } +// validateSparkDeclaration enforces the protocol's declaration-side MUSTs +// at the hosting entry: stack is required (identifies the tech-stack shape; +// official template seeds write it, custom projects use custom-webapp / +// custom-fullstack), and dev.port is required because the platform relies +// on the project's local self-description endpoint +// (GET localhost:/spark.json) after the app is hosted. +func validateSparkDeclaration(cfg *appDevProjectConfig) error { + switch { + case cfg.Stack == "": + return appsFailedPreconditionError("spark.json is missing the required stack field"). + WithHint(`declare the tech stack: official templates write it automatically; custom projects use "custom-webapp" or "custom-fullstack"`) + case !appDevTemplateNameRe.MatchString(cfg.Stack): + return appsFailedPreconditionError("spark.json stack %q is invalid (lowercase letters, digits, '.', '_', '-')", cfg.Stack). + WithHint(`use the stack name written by the template seed, or "custom-webapp" / "custom-fullstack" for custom projects`) + case !strings.HasSuffix(cfg.Stack, "-webapp") && !strings.HasSuffix(cfg.Stack, "-fullstack"): + return appsFailedPreconditionError("spark.json stack %q does not name a supported hosting shape (must end with -webapp or -fullstack)", cfg.Stack). + WithHint(`custom projects use "custom-webapp" or "custom-fullstack"; official template stacks carry the suffix already`) + case cfg.DevPort == 0: + return appsFailedPreconditionError("spark.json is missing the required dev.port field"). + WithHint(`declare the local dev-server port, e.g. {"dev": {"port": 5173}} — after hosting, platform capabilities rely on the local self-description endpoint (GET localhost:/spark.json)`) + case cfg.DevPort < 1 || cfg.DevPort > 65535: + return appsFailedPreconditionError("spark.json dev.port %d is out of range (1-65535)", cfg.DevPort) + } + return nil +} + +// warnMissingIndexHTML reports whether the same-origin payload lacks an +// output/index.html entry. The platform gateway's SPA fallback serves the +// entry HTML for unmatched paths, so publishing without one is almost +// always a broken build — kept as a warning (not a gate) per the protocol +// owner's call. +func warnMissingIndexHTML(entries []appDevPackEntry) bool { + for _, e := range entries { + if e.ZipPath == "output/index.html" { + return false + } + } + return true +} + // resolveAppDevPublishTarget loads the project declaration (spark.json // first, legacy .spark/meta.json fallback) and resolves the publish target // from --app-id and the recorded app id: @@ -391,6 +431,9 @@ var AppsDeploy = common.Shortcut{ if err != nil { return err } + if err := validateSparkDeclaration(cfg); err != nil { + return err + } // Sensitive-file scan lives in Validate so that --dry-run exits // non-zero on a hit — the one deliberate exception to dry-run's // exit-0 convention (mirrors +html-publish). Every file under the @@ -475,6 +518,9 @@ var AppsDeploy = common.Shortcut{ if gen >= 0 { dry.Set("routes_json", fmt.Sprintf("absent; will be generated from the .html tree (%d route(s))", gen)) } + if warnMissingIndexHTML(entries) { + dry.Set("index_html_warning", "no index.html in the same-origin payload; the platform's SPA fallback depends on it") + } } return dry }, @@ -535,6 +581,9 @@ var AppsDeploy = common.Shortcut{ if generatedRoutes >= 0 { fmt.Fprintf(rctx.IO().ErrOut, "routes.json not found; generated %d route(s) from the .html tree\n", generatedRoutes) } + if warnMissingIndexHTML(entries) { + fmt.Fprintf(rctx.IO().ErrOut, "warning: no index.html in %s — the platform's SPA fallback serves the entry HTML for unmatched paths, so this deploy will likely misbehave\n", cfg.BuildOutput) + } zipball, err := buildAppDevZip(rctx.FileIO(), entries) if err != nil { return err diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index 52c85ec782..f4c767e6f4 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -455,7 +455,7 @@ func TestAppDevPublishValidate_NoMeta(t *testing.T) { } func TestAppDevPublishValidate_NoAppID(t *testing.T) { - chdirSparkProjectRoot(t, `{"stack":"react-standard-webapp"}`) + chdirSparkProjectRoot(t, `{"stack":"react-standard-webapp","dev":{"port":5173}}`) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) @@ -484,7 +484,7 @@ func TestAppDevPublishValidate_AppIDMismatch(t *testing.T) { } func TestAppDevPublishExecute_FlagMatchesMeta(t *testing.T) { - root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -513,8 +513,49 @@ func TestAppDevPublishValidate_BadAppID(t *testing.T) { } } +func TestAppDevPublishValidate_Declaration(t *testing.T) { + // The hosting entry enforces the protocol's declaration-side MUSTs: + // stack (with a supported hosting-shape suffix) and dev.port (the + // platform relies on the local self-description endpoint after hosting). + cases := []struct { + name, sparkJSON, wantErr string + }{ + {"missing stack", `{"dev":{"port":5173},"app":{"id":"app_x"}}`, "missing the required stack"}, + {"bad stack charset", `{"stack":"My Stack","dev":{"port":5173},"app":{"id":"app_x"}}`, "is invalid"}, + {"bad stack suffix", `{"stack":"react-standard","dev":{"port":5173},"app":{"id":"app_x"}}`, "must end with -webapp or -fullstack"}, + {"missing dev.port", `{"stack":"custom-webapp","app":{"id":"app_x"}}`, "missing the required dev.port"}, + {"port out of range", `{"stack":"custom-webapp","dev":{"port":70000},"app":{"id":"app_x"}}`, "out of range"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + chdirSparkProjectRoot(t, tc.sparkJSON) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, tc.wantErr) { + t.Errorf("got %v, want message containing %q", p, tc.wantErr) + } + }) + } +} + +func TestAppDevPublishExecute_MissingIndexHTMLWarns(t *testing.T) { + // A payload without index.html publishes (warning only, per the protocol + // owner's call) — the platform's SPA fallback depends on it, so the + // warning must be loud but non-blocking. + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/page.html", "output/routes.json"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + stubReleases(reg, "app_x", map[string]interface{}{"release_id": "rel_60", "status": "finished", "online_url": "https://x/app/app_x"}) + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("missing index.html must not block the deploy: %v", err) + } +} + func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { - root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json", "output/.env"}) factory, stdout, _ := newAppsExecuteFactory(t) @@ -538,7 +579,7 @@ func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { } func TestAppDevPublishValidate_SkipBuildNoDist(t *testing.T) { - chdirSparkProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) + chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) @@ -550,7 +591,7 @@ func TestAppDevPublishValidate_SkipBuildNoDist(t *testing.T) { func TestAppDevPublishValidate_BuildlessNoDist(t *testing.T) { // No build.command declared in spark.json (buildless): the artifact // directory must already exist. - chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) + chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) @@ -565,6 +606,7 @@ func TestAppDevPublishValidate_BuildlessNoDist(t *testing.T) { func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { root := chdirSparkProjectRoot(t, `{ "stack": "react-standard-webapp", + "dev": { "port": 5173 }, "build": { "command": ["npm", "run", "build"], "output": "dist/output" }, "app": { "id": "app_x" } }`) @@ -631,7 +673,7 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { func TestAppDevPublishExecute_BuildlessSync(t *testing.T) { // spark.json without build.command: buildless — no build runs, // dist/output is packed as-is, the app section gains the url. - root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) f := &fakeEnvRunner{} withFakeEnvRunner(t, f) @@ -662,7 +704,7 @@ func TestAppDevPublishExecute_BuildlessSync(t *testing.T) { } func TestAppDevPublishExecute_AsyncSuccess(t *testing.T) { - root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -695,7 +737,7 @@ func TestAppDevPublishExecute_FinishedWithoutURL(t *testing.T) { // The create response may report finished without online_url; the // command must fetch the release once to recover the url instead of // returning an empty one with a misleading poll hint. - root := chdirSparkProjectRoot(t, `{"stack":"s","app":{"id":"app_x"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, appDevDefaultBuildOutput), []string{"index.html", "routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -720,7 +762,7 @@ func TestAppDevPublishExecute_FinishedWithoutURL(t *testing.T) { func TestAppDevPublishExecute_CreateReportsFailed(t *testing.T) { // A create response that already reports failed is a failed publish: // exit non-zero with the error_logs (fetched once) summarized. - root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -757,7 +799,7 @@ func TestSummarizeReleaseErrorLogs(t *testing.T) { } func TestAppDevPublishExecute_BuildFails(t *testing.T) { - chdirSparkProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) + chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) f := &fakeEnvRunner{stderr: "TS2304: boom", err: errors.New("exit 1")} withFakeEnvRunner(t, f) @@ -774,7 +816,7 @@ func TestAppDevPublishExecute_BuildFails(t *testing.T) { } func TestAppDevPublishExecute_PreReleaseMissingKVs(t *testing.T) { - root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -793,7 +835,7 @@ func TestAppDevPublishExecute_PreReleaseMissingKVs(t *testing.T) { } func TestAppDevPublishExecute_NonHTTPSUploadURL(t *testing.T) { - root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) factory, stdout, reg := newAppsExecuteFactory(t) stubPreRelease(reg, "app_x", "http://insecure.example/put", nil) @@ -805,7 +847,7 @@ func TestAppDevPublishExecute_NonHTTPSUploadURL(t *testing.T) { } func TestAppDevPublishExecute_TOS5xx(t *testing.T) { - root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }) factory, stdout, reg := newAppsExecuteFactory(t) @@ -818,7 +860,7 @@ func TestAppDevPublishExecute_TOS5xx(t *testing.T) { } func TestAppDevPublishDryRun(t *testing.T) { - root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"},"build":{"command":["npm","run","build"],"output":"dist/output"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"},"build":{"command":["npm","run","build"],"output":"dist/output"}}`) writeDistFiles(t, filepath.Join(root, "dist", "output"), []string{"index.html"}) factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout); err != nil { @@ -843,7 +885,7 @@ func TestAppDevPublishDryRun(t *testing.T) { } func TestAppDevPublishDryRun_Buildless(t *testing.T) { - root := chdirSparkProjectRoot(t, `{"app":{"id":"app_x"}}`) + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html"}) factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user", "--dry-run"}, factory, stdout); err != nil { @@ -876,6 +918,7 @@ func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { // section is replaced wholesale on success. root := chdirSparkProjectRoot(t, `{ "stack": "custom-webapp", + "dev": { "port": 5173 }, "build": { "command": ["make", "site"], "output": "public" }, "app": { "id": "app_x" } }`) @@ -914,7 +957,7 @@ func TestAppDevPublishExecute_MiaodaProtocol(t *testing.T) { func TestAppDevPublishExecute_MiaodaFlagBackfill(t *testing.T) { // No recorded app id in spark.json: --app-id publishes and the app // section is written on success (async: no url yet). - root := chdirSparkProjectRoot(t, `{"stack":"react-standard-webapp"}`) + root := chdirSparkProjectRoot(t, `{"stack":"react-standard-webapp","dev":{"port":5173}}`) writeDistFiles(t, filepath.Join(root, appDevDefaultBuildOutput), []string{"index.html", "routes.json"}) srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) factory, stdout, reg := newAppsExecuteFactory(t) diff --git a/shortcuts/apps/apps_spark_config.go b/shortcuts/apps/apps_spark_config.go index 9858f30190..4c962b2184 100644 --- a/shortcuts/apps/apps_spark_config.go +++ b/shortcuts/apps/apps_spark_config.go @@ -39,8 +39,12 @@ type appDevProjectConfig struct { // BuildOutputCDN is the CDN artifact directory; empty means Level 1 // (no CDN split). BuildOutputCDN string - AppID string - AppURL string + // DevPort is the declared local dev-server port; 0 means undeclared. + // Hosted projects must declare it — the platform relies on the local + // self-description endpoint (GET localhost:/spark.json). + DevPort int + AppID string + AppURL string } // sparkJSONDoc mirrors the spark.json schema (§3). Unknown fields are @@ -49,7 +53,10 @@ type appDevProjectConfig struct { type sparkJSONDoc struct { Stack string `json:"stack"` Version string `json:"version"` - Build struct { + Dev struct { + Port int `json:"port"` + } `json:"dev"` + Build struct { Command []string `json:"command"` Output string `json:"output"` OutputCDN string `json:"output_cdn"` @@ -77,8 +84,9 @@ func readAppDevProjectConfig(dir string) (cfg *appDevProjectConfig, found bool, return nil, true, appsFileIOError(jerr, "parse %s failed: %v", sparkJSONRelPath, jerr) } cfg = &appDevProjectConfig{ - Stack: doc.Stack, + Stack: strings.TrimSpace(doc.Stack), Version: doc.Version, + DevPort: doc.Dev.Port, BuildCommand: doc.Build.Command, BuildOutput: strings.TrimSpace(doc.Build.Output), BuildOutputCDN: strings.TrimSpace(doc.Build.OutputCDN), diff --git a/skills/lark-apps/references/lark-apps-deploy.md b/skills/lark-apps/references/lark-apps-deploy.md index 08106892a1..b364a29d31 100644 --- a/skills/lark-apps/references/lark-apps-deploy.md +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -44,6 +44,9 @@ lark-cli apps +deploy --dry-run ## 常见失败 - `current directory is not a Miaoda app project`:不在项目根执行;`cd` 到含 `spark.json` 的目录。 +- `spark.json is missing the required stack field` / `stack ... must end with -webapp or -fullstack`:声明技术栈——官方模板自动写入;自定义项目填 `custom-webapp` / `custom-fullstack`。 +- `spark.json is missing the required dev.port field`:声明本地 dev 端口(如 `{"dev":{"port":5173}}`)——托管后平台能力依托本地自描述端点(`GET localhost:/spark.json`),必填。 +- `warning: no index.html ...`:不拦截但强烈建议修复——平台 SPA fallback 依赖入口 index.html,缺失时线上路由回退会异常。 - `routes.json is missing` / schema 校验失败:声明了 `build.command` 的项目由构建脚本负责生成合法 routes.json;让用户检查构建配置,不要手工伪造(buildless 项目无此问题,CLI 会自动生成)。 - `build command ... failed`:转述 stderr 摘要让用户修构建错误(构建命令来自 spark.json `build.command`);用户已手动构建时可用 `--skip-build`。 - `artifact directory ... does not exist`:声明了构建命令时先构建(或去掉 `--skip-build`);buildless 项目需确认 `build.output` 指向的目录真实存在。 From a818db50a36dfbb8905083e3a61812f2c67b5897 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Sat, 29 Aug 2026 15:15:26 +0800 Subject: [PATCH 38/51] polish(apps): clarify stack charset error; pin the declaration gate on dry-run Review follow-ups: the charset message now states the first-character rule, and the declaration matrix runs through --dry-run to pin that the protocol gate blocks previews the same way it blocks real runs. --- shortcuts/apps/apps_deploy.go | 2 +- shortcuts/apps/apps_deploy_test.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index d46029e008..91ec800f70 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -241,7 +241,7 @@ func validateSparkDeclaration(cfg *appDevProjectConfig) error { return appsFailedPreconditionError("spark.json is missing the required stack field"). WithHint(`declare the tech stack: official templates write it automatically; custom projects use "custom-webapp" or "custom-fullstack"`) case !appDevTemplateNameRe.MatchString(cfg.Stack): - return appsFailedPreconditionError("spark.json stack %q is invalid (lowercase letters, digits, '.', '_', '-')", cfg.Stack). + return appsFailedPreconditionError("spark.json stack %q is invalid (must start with a lowercase letter or digit; then lowercase letters, digits, '.', '_', '-')", cfg.Stack). WithHint(`use the stack name written by the template seed, or "custom-webapp" / "custom-fullstack" for custom projects`) case !strings.HasSuffix(cfg.Stack, "-webapp") && !strings.HasSuffix(cfg.Stack, "-fullstack"): return appsFailedPreconditionError("spark.json stack %q does not name a supported hosting shape (must end with -webapp or -fullstack)", cfg.Stack). diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index f4c767e6f4..11452bd1d6 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -530,7 +530,9 @@ func TestAppDevPublishValidate_Declaration(t *testing.T) { t.Run(tc.name, func(t *testing.T) { chdirSparkProjectRoot(t, tc.sparkJSON) factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout) + // The declaration gate lives in Validate, so --dry-run is blocked + // the same way a real run is (protocol gate, not a preview detail). + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user", "--dry-run"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, tc.wantErr) { t.Errorf("got %v, want message containing %q", p, tc.wantErr) From 68d00992a433a57791fc7005300fca4d45de5c8a Mon Sep 17 00:00:00 2001 From: duanlikang Date: Sat, 29 Aug 2026 15:27:29 +0800 Subject: [PATCH 39/51] feat(apps): enforce the local self-description endpoint at deploy time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit +deploy now requires GET 127.0.0.1:/spark.json to serve a valid declaration (start-the-dev-server guidance on failure), and the endpoint's app.id must match the deploy directory's — shipping one project's payload onto another project's app is refused. Only a fresh project with no app id on either side skips the comparison. --- shortcuts/apps/apps_deploy.go | 69 ++++++++++++++++++ shortcuts/apps/apps_deploy_test.go | 113 +++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index 91ec800f70..21114e53be 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -18,6 +18,7 @@ import ( "path/filepath" "sort" "strings" + "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" @@ -255,6 +256,71 @@ func validateSparkDeclaration(cfg *appDevProjectConfig) error { return nil } +// appDevEndpointProbeTimeout bounds the local self-description probe; the +// target is a loopback dev server, so a healthy endpoint answers in +// milliseconds. Var so tests can shrink it. +var appDevEndpointProbeTimeout = 2 * time.Second + +// probeLocalSparkEndpoint fetches the protocol's local self-description +// endpoint (GET 127.0.0.1:/spark.json) and returns the app id it +// declares ("" when the served declaration carries none). Any failure to +// reach a valid endpoint — no listener, non-200, unreadable body, invalid +// JSON — comes back as an error naming the reason. +func probeLocalSparkEndpoint(port int) (appID string, err error) { + client := &http.Client{Timeout: appDevEndpointProbeTimeout} //nolint:forbidigo // loopback probe of the project's own dev server; not a Lark API call. + resp, gerr := client.Get(fmt.Sprintf("http://127.0.0.1:%d/spark.json", port)) //nolint:forbidigo // loopback probe of the project's own dev server; not a Lark API call. + if gerr != nil { + return "", fmt.Errorf("no dev server reachable on 127.0.0.1:%d", port) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GET 127.0.0.1:%d/spark.json returned HTTP %d", port, resp.StatusCode) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. + } + body, rerr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if rerr != nil { + return "", fmt.Errorf("reading 127.0.0.1:%d/spark.json failed: %w", port, rerr) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. + } + var doc struct { + App struct { + ID string `json:"id"` + } `json:"app"` + } + if json.Unmarshal(body, &doc) != nil { + return "", fmt.Errorf("127.0.0.1:%d/spark.json is not valid JSON", port) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. + } + return strings.TrimSpace(doc.App.ID), nil +} + +// appDevProbeLocalEndpoint is the injectable seam for the local +// self-description probe (unit tests stub it; the hard gate below must not +// force every test through a live loopback server). +var appDevProbeLocalEndpoint = probeLocalSparkEndpoint + +// verifyLocalEndpointIdentity is the hosting entry's enforcement of the +// protocol's local self-description endpoint plus the cross-project deploy +// guard: the dev server MUST be running and serving /spark.json, and when +// either side declares an app id the two MUST match — a mismatch means the +// running project is not the one being deployed, which would ship this +// payload onto another project's app. Only the "neither side has an app id +// yet" case skips the comparison (first deploy of a fresh project). +func verifyLocalEndpointIdentity(cfg *appDevProjectConfig) error { + endpointID, err := appDevProbeLocalEndpoint(cfg.DevPort) + if err != nil { + return appsFailedPreconditionError("the local self-description endpoint is unavailable: %v", err). + WithHint(fmt.Sprintf("start the dev server (spark.json dev.command, port %d) before deploying — the platform requires GET /spark.json to serve the project declaration (official templates ship this endpoint; custom projects must serve the project-root spark.json themselves)", cfg.DevPort)) + } + if endpointID == "" && cfg.AppID == "" { + return nil + } + if endpointID != cfg.AppID { + return appsFailedPreconditionError( + "the dev server on 127.0.0.1:%d declares app %q, but this directory deploys app %q — refusing to ship one project's payload onto another project's app", + cfg.DevPort, endpointID, cfg.AppID). + WithHint("you are likely deploying from the wrong directory (or the wrong dev server is running on this port); deploy from the project that owns the running dev server, or restart the right one") + } + return nil +} + // warnMissingIndexHTML reports whether the same-origin payload lacks an // output/index.html entry. The platform gateway's SPA fallback serves the // entry HTML for unmatched paths, so publishing without one is almost @@ -434,6 +500,9 @@ var AppsDeploy = common.Shortcut{ if err := validateSparkDeclaration(cfg); err != nil { return err } + if err := verifyLocalEndpointIdentity(cfg); err != nil { + return err + } // Sensitive-file scan lives in Validate so that --dry-run exits // non-zero on a hit — the one deliberate exception to dry-run's // exit-0 convention (mirrors +html-publish). Every file under the diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index 11452bd1d6..89ea3e42b6 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "net" "net/http" "net/http/httptest" "os" @@ -370,6 +371,10 @@ func withFakeEnvRunner(t *testing.T, f *fakeEnvRunner) { // chdirs into it (the protocol-first path). func chdirSparkProjectRoot(t *testing.T, miaodaJSON string) string { t.Helper() + // Default the local self-description probe to "endpoint agrees with the + // fixture" so the hard gate stays out of unrelated tests' way; gate tests + // install their own stub or a real loopback server. + stubLocalEndpoint(t, sparkAppIDOf(miaodaJSON), nil) root := t.TempDir() if miaodaJSON != "" { if err := os.WriteFile(filepath.Join(root, sparkJSONRelPath), []byte(miaodaJSON), 0o644); err != nil { @@ -556,6 +561,114 @@ func TestAppDevPublishExecute_MissingIndexHTMLWarns(t *testing.T) { } } +// sparkAppIDOf extracts app.id from a fixture spark.json string ("" when +// absent or unparsable). +func sparkAppIDOf(miaodaJSON string) string { + var doc struct { + App struct { + ID string `json:"id"` + } `json:"app"` + } + if json.Unmarshal([]byte(miaodaJSON), &doc) != nil { + return "" + } + return strings.TrimSpace(doc.App.ID) +} + +// stubLocalEndpoint swaps the local self-description probe for this test. +func stubLocalEndpoint(t *testing.T, appID string, err error) { + t.Helper() + orig := appDevProbeLocalEndpoint + appDevProbeLocalEndpoint = func(int) (string, error) { return appID, err } + t.Cleanup(func() { appDevProbeLocalEndpoint = orig }) +} + +// localEndpointServer runs a plain-HTTP dev-server stand-in on a loopback +// port serving /spark.json, restores the real probe, and returns the port. +func localEndpointServer(t *testing.T, body string, status int) int { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/spark.json" { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + orig := appDevProbeLocalEndpoint + appDevProbeLocalEndpoint = probeLocalSparkEndpoint + t.Cleanup(func() { appDevProbeLocalEndpoint = orig }) + return srv.Listener.Addr().(*net.TCPAddr).Port +} + +func TestVerifyLocalEndpointIdentity(t *testing.T) { + cfgWith := func(appID string, port int) *appDevProjectConfig { + return &appDevProjectConfig{AppID: appID, DevPort: port} + } + t.Run("mismatch is rejected", func(t *testing.T) { + port := localEndpointServer(t, `{"stack":"custom-webapp","app":{"id":"app_other"}}`, 200) + err := verifyLocalEndpointIdentity(cfgWith("app_mine", port)) + if err == nil || !strings.Contains(err.Error(), "app_other") || !strings.Contains(err.Error(), "app_mine") { + t.Errorf("mismatch must be rejected naming both ids, got %v", err) + } + }) + t.Run("matching id passes", func(t *testing.T) { + port := localEndpointServer(t, `{"app":{"id":"app_mine"}}`, 200) + if err := verifyLocalEndpointIdentity(cfgWith("app_mine", port)); err != nil { + t.Errorf("matching identity must pass: %v", err) + } + }) + t.Run("both without app id pass", func(t *testing.T) { + port := localEndpointServer(t, `{"stack":"custom-webapp"}`, 200) + if err := verifyLocalEndpointIdentity(cfgWith("", port)); err != nil { + t.Errorf("fresh project on both sides must pass: %v", err) + } + }) + t.Run("endpoint without app id but deploy dir with one is rejected", func(t *testing.T) { + port := localEndpointServer(t, `{"stack":"custom-webapp"}`, 200) + if err := verifyLocalEndpointIdentity(cfgWith("app_mine", port)); err == nil { + t.Error("one-sided app id must be rejected (served declaration disagrees with the deploy dir)") + } + }) + t.Run("non-json endpoint is rejected", func(t *testing.T) { + port := localEndpointServer(t, "not a spark project", 200) + err := verifyLocalEndpointIdentity(cfgWith("app_mine", port)) + if err == nil || !strings.Contains(err.Error(), "not valid JSON") { + t.Errorf("non-JSON endpoint must be rejected: %v", err) + } + }) + t.Run("no dev server is rejected with guidance", func(t *testing.T) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + orig := appDevProbeLocalEndpoint + appDevProbeLocalEndpoint = probeLocalSparkEndpoint + defer func() { appDevProbeLocalEndpoint = orig }() + gerr := verifyLocalEndpointIdentity(cfgWith("app_mine", port)) + p, _ := errs.ProblemOf(gerr) + if p == nil || !strings.Contains(p.Message, "unavailable") || !strings.Contains(p.Hint, "start the dev server") { + t.Errorf("missing dev server must hard-fail with start guidance, got %v", gerr) + } + }) +} + +func TestAppDevPublishValidate_EndpointGateOnDryRun(t *testing.T) { + // The endpoint gate lives in Validate: a mismatching dev server blocks + // --dry-run the same way it blocks a real deploy. + chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) + stubLocalEndpoint(t, "app_other", nil) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user", "--dry-run"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if p.Subtype != errs.SubtypeFailedPrecondition || !strings.Contains(p.Message, "app_other") { + t.Errorf("got %v", p) + } +} + func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), From 1d9fa88bb2cf0b2bc3e4e22498d3fd2cbcc58731 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Sat, 29 Aug 2026 15:35:09 +0800 Subject: [PATCH 40/51] feat(apps): relax +deploy gates; add --no-verify Removed: the stack value checks, the credential-file scan (and its --allow-sensitive flag), the declared-but-missing CDN directory error (now skipped with no CDN entries), and the client-side payload size caps (the server enforces its own). Added --no-verify to bypass the local dev-server verification (endpoint reachability + app-identity match) for headless environments; the dev.port declaration itself stays required. --- shortcuts/apps/apps_deploy.go | 94 +++++----------------- shortcuts/apps/apps_deploy_test.go | 123 +++++++++-------------------- shortcuts/apps/apps_deploy_zip.go | 26 ------ 3 files changed, 56 insertions(+), 187 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index 21114e53be..b2c6637b89 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -69,9 +69,9 @@ func appDevBuildEnv(kvm map[string]string) (env []string, keys []string) { // generated from the .html tree for buildless projects when absent (never // overwriting a project-provided one), and required from the build // otherwise. generatedRoutes is the generated route count, or -1 when the -// project shipped its own routes.json. allowSensitive skips the -// credential-file scan (every listed file is uploaded, so all are scanned). -func validateAppDevOutputs(fio fileio.FileIO, cfg *appDevProjectConfig, allowSensitive bool) (entries []appDevPackEntry, generatedRoutes int, err error) { +// project shipped its own routes.json. A declared but missing CDN directory +// is skipped (no CDN entries), not an error. +func validateAppDevOutputs(fio fileio.FileIO, cfg *appDevProjectConfig) (entries []appDevPackEntry, generatedRoutes int, err error) { generatedRoutes = -1 outFiles, err := walkHTMLPublishCandidates(fio, cfg.BuildOutput) if err != nil { @@ -88,7 +88,6 @@ func validateAppDevOutputs(fio fileio.FileIO, cfg *appDevProjectConfig, allowSen return nil, -1, err } var htmlRels []string - var sensitive []string hasRoutes := false for _, c := range outFiles { if strings.HasSuffix(c.RelPath, ".html") { @@ -97,30 +96,21 @@ func validateAppDevOutputs(fio fileio.FileIO, cfg *appDevProjectConfig, allowSen if c.RelPath == "routes.json" { hasRoutes = true } - if !allowSensitive && isSensitiveCandidate(cfg.BuildOutput, c) { - sensitive = append(sensitive, filepath.ToSlash(filepath.Join(cfg.BuildOutput, c.RelPath))) - } entries = append(entries, appDevPackEntry{ZipPath: "output/" + c.RelPath, AbsPath: c.AbsPath, Size: c.Size}) } if cfg.BuildOutputCDN != "" { cdnFiles, err := walkHTMLPublishCandidates(fio, cfg.BuildOutputCDN) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil, -1, appsFailedPreconditionError( - "CDN artifact directory %s not found (declared in spark.json build.output_cdn)", cfg.BuildOutputCDN). - WithHint("make the build produce it, or drop build.output_cdn to publish without the CDN split") + switch { + case err == nil: + for _, c := range cdnFiles { + entries = append(entries, appDevPackEntry{ZipPath: "output_resource/" + c.RelPath, AbsPath: c.AbsPath, Size: c.Size}) } + case errors.Is(err, fs.ErrNotExist): + // A declared but not-yet-produced CDN directory just means no CDN + // entries this round. + default: return nil, -1, err } - for _, c := range cdnFiles { - if !allowSensitive && isSensitiveCandidate(cfg.BuildOutputCDN, c) { - sensitive = append(sensitive, filepath.ToSlash(filepath.Join(cfg.BuildOutputCDN, c.RelPath))) - } - entries = append(entries, appDevPackEntry{ZipPath: "output_resource/" + c.RelPath, AbsPath: c.AbsPath, Size: c.Size}) - } - } - if len(sensitive) > 0 { - return nil, -1, appDevSensitiveCandidatesError(sensitive) } if len(htmlRels) == 0 { return nil, -1, appsFailedPreconditionError( @@ -219,34 +209,12 @@ func validateAppDevRoutesJSON(b []byte) error { return nil } -// appDevSensitiveCandidatesError mirrors sensitiveCandidatesError with -// publish-specific wording: this command has no --path flag — the payload is -// the declared artifact directories — so the html-publish message would -// misdirect the user. -func appDevSensitiveCandidatesError(hits []string) error { - return appsValidationError( - "the publish payload contains %d credential file(s) that should not be published: %s", - len(hits), truncatedJoin(hits, maxSensitiveListInError)). - WithHint("remove these files from the artifact directories, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)") -} - -// validateSparkDeclaration enforces the protocol's declaration-side MUSTs -// at the hosting entry: stack is required (identifies the tech-stack shape; -// official template seeds write it, custom projects use custom-webapp / -// custom-fullstack), and dev.port is required because the platform relies -// on the project's local self-description endpoint +// validateSparkDeclaration enforces the declaration-side gate at the +// hosting entry: dev.port is required because the platform relies on the +// project's local self-description endpoint // (GET localhost:/spark.json) after the app is hosted. func validateSparkDeclaration(cfg *appDevProjectConfig) error { switch { - case cfg.Stack == "": - return appsFailedPreconditionError("spark.json is missing the required stack field"). - WithHint(`declare the tech stack: official templates write it automatically; custom projects use "custom-webapp" or "custom-fullstack"`) - case !appDevTemplateNameRe.MatchString(cfg.Stack): - return appsFailedPreconditionError("spark.json stack %q is invalid (must start with a lowercase letter or digit; then lowercase letters, digits, '.', '_', '-')", cfg.Stack). - WithHint(`use the stack name written by the template seed, or "custom-webapp" / "custom-fullstack" for custom projects`) - case !strings.HasSuffix(cfg.Stack, "-webapp") && !strings.HasSuffix(cfg.Stack, "-fullstack"): - return appsFailedPreconditionError("spark.json stack %q does not name a supported hosting shape (must end with -webapp or -fullstack)", cfg.Stack). - WithHint(`custom projects use "custom-webapp" or "custom-fullstack"; official template stacks carry the suffix already`) case cfg.DevPort == 0: return appsFailedPreconditionError("spark.json is missing the required dev.port field"). WithHint(`declare the local dev-server port, e.g. {"dev": {"port": 5173}} — after hosting, platform capabilities rely on the local self-description endpoint (GET localhost:/spark.json)`) @@ -490,7 +458,7 @@ var AppsDeploy = common.Shortcut{ Flags: []common.Flag{ {Name: "app-id", Desc: "publish target app ID (app_ prefix); optional when spark.json already records one — on a successful publish it is saved back into spark.json, and a value conflicting with the recorded one is rejected"}, {Name: "skip-build", Type: "bool", Desc: "skip the build.command declared in spark.json and publish the existing build.output directory as-is (no effect on buildless projects, which never build)"}, - {Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / etc. in the publish payload)"}, + {Name: "no-verify", Type: "bool", Desc: "skip the local dev-server verification (GET 127.0.0.1:/spark.json availability and app-identity match); the dev.port declaration itself is still required"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { cfg, _, _, err := resolveAppDevPublishTarget(rctx) @@ -500,31 +468,9 @@ var AppsDeploy = common.Shortcut{ if err := validateSparkDeclaration(cfg); err != nil { return err } - if err := verifyLocalEndpointIdentity(cfg); err != nil { - return err - } - // Sensitive-file scan lives in Validate so that --dry-run exits - // non-zero on a hit — the one deliberate exception to dry-run's - // exit-0 convention (mirrors +html-publish). Every file under the - // declared artifact directories is uploaded, so all are scanned. - // Walk errors (e.g. directory missing) are not fatal here; - // DryRun/Execute surface them with richer context. - if !rctx.Bool("allow-sensitive") { - var hits []string - for _, dir := range []string{cfg.BuildOutput, cfg.BuildOutputCDN} { - if dir == "" { - continue - } - if candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), dir); err == nil { - for _, c := range candidates { - if isSensitiveCandidate(dir, c) { - hits = append(hits, filepath.ToSlash(filepath.Join(dir, c.RelPath))) - } - } - } - } - if len(hits) > 0 { - return appDevSensitiveCandidatesError(hits) + if !rctx.Bool("no-verify") { + if err := verifyLocalEndpointIdentity(cfg); err != nil { + return err } } switch { @@ -580,7 +526,7 @@ var AppsDeploy = common.Shortcut{ } else { dry.Set("build_output_cdn", "(not declared: no CDN split, all assets served same-origin)") } - if entries, gen, verr := validateAppDevOutputs(rctx.FileIO(), cfg, rctx.Bool("allow-sensitive")); verr != nil { + if entries, gen, verr := validateAppDevOutputs(rctx.FileIO(), cfg); verr != nil { dry.Set("output_validation_error", verr.Error()) } else { dry.Set("upload_file_count", len(entries)) @@ -643,7 +589,7 @@ var AppsDeploy = common.Shortcut{ built = true } - entries, generatedRoutes, err := validateAppDevOutputs(rctx.FileIO(), cfg, rctx.Bool("allow-sensitive")) + entries, generatedRoutes, err := validateAppDevOutputs(rctx.FileIO(), cfg) if err != nil { return err } diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index 89ea3e42b6..704bae5bc6 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net" "net/http" "net/http/httptest" @@ -100,7 +101,7 @@ func TestValidateAppDevOutputs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { out := filepath.Join(t.TempDir(), "dist", "output") writeDistFiles(t, out, tt.files) - entries, gen, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", tt.buildless), false) + entries, gen, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", tt.buildless)) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("err = %v, want containing %q", err, tt.wantErr) @@ -141,7 +142,7 @@ func TestValidateAppDevOutputs_CDNSplit(t *testing.T) { cdn := filepath.Join(root, "dist", "output_resource") writeDistFiles(t, out, []string{"index.html", "routes.json"}) writeDistFiles(t, cdn, []string{"static/a.js"}) - entries, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, cdn, false), false) + entries, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, cdn, false)) if err != nil { t.Fatal(err) } @@ -154,10 +155,15 @@ func TestValidateAppDevOutputs_CDNSplit(t *testing.T) { t.Errorf("missing normalized entry %q in %v", want, got) } } - // A declared but missing CDN directory is a hard error, not silence. - _, _, err = validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, filepath.Join(root, "nope"), false), false) - if err == nil || !strings.Contains(err.Error(), "CDN artifact directory") { - t.Errorf("missing declared cdn dir must fail, got %v", err) + // A declared but not-yet-produced CDN directory is skipped, not an error. + entries2, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, filepath.Join(root, "nope"), false)) + if err != nil { + t.Fatalf("missing declared cdn dir must be skipped: %v", err) + } + for _, e := range entries2 { + if strings.HasPrefix(e.ZipPath, "output_resource/") { + t.Errorf("no cdn entries expected when the dir is absent, got %s", e.ZipPath) + } } } @@ -195,7 +201,7 @@ func TestValidateAppDevOutputs_RoutesSchema(t *testing.T) { check := func(body, wantErr string) { t.Helper() set(body) - _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false), false) + _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false)) if wantErr == "" { if err != nil { t.Errorf("routes %q should be valid: %v", body, err) @@ -219,7 +225,7 @@ func TestValidateAppDevOutputs_RoutesSchema(t *testing.T) { func TestValidateAppDevOutputs_Missing(t *testing.T) { missing := filepath.Join(t.TempDir(), "dist", "output") - _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(missing, "", false), false) + _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(missing, "", false)) p := requireAppsProblem(t, err, errs.CategoryValidation) if p.Subtype != errs.SubtypeFailedPrecondition { t.Errorf("subtype = %q, want failed_precondition", p.Subtype) @@ -228,25 +234,13 @@ func TestValidateAppDevOutputs_Missing(t *testing.T) { t.Errorf("hint = %q", p.Hint) } // Buildless projects get buildless-specific guidance, not a build hint. - _, _, err = validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(missing, "", true), false) + _, _, err = validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(missing, "", true)) p = requireAppsProblem(t, err, errs.CategoryValidation) if !strings.Contains(p.Hint, "no build.command") { t.Errorf("buildless hint = %q", p.Hint) } } -func TestValidateAppDevOutputs_Sensitive(t *testing.T) { - out := filepath.Join(t.TempDir(), "dist", "output") - writeDistFiles(t, out, []string{"index.html", "routes.json", ".env"}) - _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false), false) - if err == nil || !strings.Contains(err.Error(), "credential file") { - t.Errorf("sensitive file must be rejected, got %v", err) - } - if _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false), true); err != nil { - t.Errorf("allow-sensitive must waive the scan: %v", err) - } -} - // --- zip packing --- func TestBuildAppDevZip(t *testing.T) { @@ -254,7 +248,7 @@ func TestBuildAppDevZip(t *testing.T) { out, cdn := filepath.Join(root, "dist", "output"), filepath.Join(root, "dist", "output_resource") writeDistFiles(t, out, []string{"index.html", "routes.json"}) writeDistFiles(t, cdn, []string{"a.js"}) - entries, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, cdn, false), false) + entries, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, cdn, false)) if err != nil { t.Fatal(err) } @@ -280,7 +274,7 @@ func TestBuildAppDevZip(t *testing.T) { func TestBuildAppDevZip_InlineGeneratedRoutes(t *testing.T) { out := filepath.Join(t.TempDir(), "dist", "output") writeDistFiles(t, out, []string{"index.html"}) - entries, gen, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", true), false) + entries, gen, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", true)) if err != nil { t.Fatal(err) } @@ -303,41 +297,6 @@ func TestBuildAppDevZip_InlineGeneratedRoutes(t *testing.T) { } } -func TestBuildAppDevZip_RawSizeCap(t *testing.T) { - orig := maxAppDevPublishRawBytes - maxAppDevPublishRawBytes = 1 - t.Cleanup(func() { maxAppDevPublishRawBytes = orig }) - out := filepath.Join(t.TempDir(), "dist", "output") - writeDistFiles(t, out, []string{"index.html", "routes.json"}) - entries, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false), false) - if err != nil { - t.Fatal(err) - } - if _, err := buildAppDevZip(permissiveFIO{}, entries); err == nil || !strings.Contains(err.Error(), "exceeds") { - t.Errorf("raw cap must reject, got %v", err) - } -} - -func TestBuildAppDevZip_ZipSizeCap(t *testing.T) { - orig := maxAppDevPublishZipBytes - maxAppDevPublishZipBytes = 1 - t.Cleanup(func() { maxAppDevPublishZipBytes = orig }) - out := filepath.Join(t.TempDir(), "dist", "output") - writeDistFiles(t, out, []string{"index.html", "routes.json"}) - entries, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", false), false) - if err != nil { - t.Fatal(err) - } - _, err = buildAppDevZip(permissiveFIO{}, entries) - if err == nil || !strings.Contains(err.Error(), "packed zip size") { - t.Errorf("zip cap must reject, got %v", err) - } - p, _ := errs.ProblemOf(err) - if p == nil || !strings.Contains(p.Hint, "reduce the artifact directory contents") { - t.Errorf("hint = %v", p) - } -} - // --- shortcut orchestration --- // fakeEnvRunner records the build invocation and optionally materializes dist @@ -519,15 +478,11 @@ func TestAppDevPublishValidate_BadAppID(t *testing.T) { } func TestAppDevPublishValidate_Declaration(t *testing.T) { - // The hosting entry enforces the protocol's declaration-side MUSTs: - // stack (with a supported hosting-shape suffix) and dev.port (the + // The hosting entry enforces the declaration-side gate: dev.port (the // platform relies on the local self-description endpoint after hosting). cases := []struct { name, sparkJSON, wantErr string }{ - {"missing stack", `{"dev":{"port":5173},"app":{"id":"app_x"}}`, "missing the required stack"}, - {"bad stack charset", `{"stack":"My Stack","dev":{"port":5173},"app":{"id":"app_x"}}`, "is invalid"}, - {"bad stack suffix", `{"stack":"react-standard","dev":{"port":5173},"app":{"id":"app_x"}}`, "must end with -webapp or -fullstack"}, {"missing dev.port", `{"stack":"custom-webapp","app":{"id":"app_x"}}`, "missing the required dev.port"}, {"port out of range", `{"stack":"custom-webapp","dev":{"port":70000},"app":{"id":"app_x"}}`, "out of range"}, } @@ -656,6 +611,24 @@ func TestVerifyLocalEndpointIdentity(t *testing.T) { }) } +func TestAppDevPublishValidate_NoVerifySkipsEndpointGate(t *testing.T) { + // --no-verify bypasses the dev-server verification (endpoint reachability + // and identity match) while the dev.port declaration stays required. + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) + stubLocalEndpoint(t, "", fmt.Errorf("no dev server reachable")) + writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--no-verify", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + t.Fatalf("--no-verify must skip the endpoint gate: %v", err) + } + // Without the flag the same state is blocked. + err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user", "--dry-run"}, factory, stdout) + p := requireAppsProblem(t, err, errs.CategoryValidation) + if !strings.Contains(p.Message, "unavailable") { + t.Errorf("got %v", p) + } +} + func TestAppDevPublishValidate_EndpointGateOnDryRun(t *testing.T) { // The endpoint gate lives in Validate: a mismatching dev server blocks // --dry-run the same way it blocks a real deploy. @@ -669,30 +642,6 @@ func TestAppDevPublishValidate_EndpointGateOnDryRun(t *testing.T) { } } -func TestAppDevPublishValidate_SensitiveGatesDryRun(t *testing.T) { - root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) - writeDistFiles(t, filepath.Join(root, "dist"), - []string{"output/index.html", "output/routes.json", "output/.env"}) - factory, stdout, _ := newAppsExecuteFactory(t) - // Sensitive hits are the one exception to dry-run's exit-0 convention: - // Validate rejects before the DryRun branch runs. - err := runAppsShortcut(t, AppsDeploy, - []string{"+deploy", "--skip-build", "--as", "user", "--dry-run"}, factory, stdout) - p := requireAppsProblem(t, err, errs.CategoryValidation) - if !strings.Contains(p.Message, "publish payload contains") || !strings.Contains(p.Message, "credential file") { - t.Errorf("message = %q", p.Message) - } - // This command has no --path flag; the error must not mention one. - if strings.Contains(p.Message, "--path") { - t.Errorf("error must not reference a nonexistent --path flag: %q", p.Message) - } - // --allow-sensitive waives the gate and dry-run goes back to exit 0. - if err := runAppsShortcut(t, AppsDeploy, - []string{"+deploy", "--skip-build", "--allow-sensitive", "--as", "user", "--dry-run"}, factory, stdout); err != nil { - t.Errorf("allow-sensitive dry-run should pass: %v", err) - } -} - func TestAppDevPublishValidate_SkipBuildNoDist(t *testing.T) { chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"},"build":{"command":["npm","run","build"]}}`) factory, stdout, _ := newAppsExecuteFactory(t) diff --git a/shortcuts/apps/apps_deploy_zip.go b/shortcuts/apps/apps_deploy_zip.go index 21f6268ddd..56d594e4ed 100644 --- a/shortcuts/apps/apps_deploy_zip.go +++ b/shortcuts/apps/apps_deploy_zip.go @@ -11,17 +11,6 @@ import ( "github.com/larksuite/cli/extension/fileio" ) -// Size caps for the app-dev publish payload. Defaults pending server-side -// confirmation; vars (not consts) so unit tests can shrink them to cover the -// rejection paths. -var ( - // maxAppDevPublishRawBytes caps total uncompressed input, defending - // against decompression-bomb style inputs before they balloon memory. - maxAppDevPublishRawBytes int64 = 200 * 1024 * 1024 - // maxAppDevPublishZipBytes caps the packed zip payload. - maxAppDevPublishZipBytes int64 = 50 * 1024 * 1024 -) - // appDevZipball is an in-memory zip payload ready for TOS upload. type appDevZipball struct { Body []byte @@ -45,16 +34,6 @@ type appDevPackEntry struct { // names are the fixed output/... and output_resource/... layout the hosting // pipeline expects. func buildAppDevZip(fio fileio.FileIO, entries []appDevPackEntry) (*appDevZipball, error) { - var rawTotal int64 - for _, e := range entries { - rawTotal += e.Size - } - if rawTotal > maxAppDevPublishRawBytes { - return nil, appsValidationError( - "publish payload total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", - rawTotal, maxAppDevPublishRawBytes). - WithHint("reduce the artifact directory contents before publishing") - } var buf bytes.Buffer zw := zip.NewWriter(&buf) for _, e := range entries { @@ -82,10 +61,5 @@ func buildAppDevZip(fio fileio.FileIO, entries []appDevPackEntry) (*appDevZipbal return nil, appsFileIOError(err, "zip finalize failed: %v", err) } size := int64(buf.Len()) - if size > maxAppDevPublishZipBytes { - return nil, appsValidationError( - "packed zip size %d bytes exceeds %d bytes limit", size, maxAppDevPublishZipBytes). - WithHint("reduce the artifact directory contents; large media should be served from external storage") - } return &appDevZipball{Body: buf.Bytes(), Size: size, FileCount: len(entries)}, nil } From f14137b19d6ba62d6e4f28af28381491737abb41 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Sat, 29 Aug 2026 15:40:02 +0800 Subject: [PATCH 41/51] feat(apps): --no-verify also waives the dev.port declaration requirement The flag now skips the whole local dev-server verification cluster: the dev.port declaration, the endpoint availability check, and the app-identity match. --- shortcuts/apps/apps_deploy.go | 8 ++++---- shortcuts/apps/apps_deploy_test.go | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index b2c6637b89..25f567114a 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -458,17 +458,17 @@ var AppsDeploy = common.Shortcut{ Flags: []common.Flag{ {Name: "app-id", Desc: "publish target app ID (app_ prefix); optional when spark.json already records one — on a successful publish it is saved back into spark.json, and a value conflicting with the recorded one is rejected"}, {Name: "skip-build", Type: "bool", Desc: "skip the build.command declared in spark.json and publish the existing build.output directory as-is (no effect on buildless projects, which never build)"}, - {Name: "no-verify", Type: "bool", Desc: "skip the local dev-server verification (GET 127.0.0.1:/spark.json availability and app-identity match); the dev.port declaration itself is still required"}, + {Name: "no-verify", Type: "bool", Desc: "skip the local dev-server verification entirely (the dev.port declaration requirement, the GET 127.0.0.1:/spark.json availability check, and the app-identity match)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { cfg, _, _, err := resolveAppDevPublishTarget(rctx) if err != nil { return err } - if err := validateSparkDeclaration(cfg); err != nil { - return err - } if !rctx.Bool("no-verify") { + if err := validateSparkDeclaration(cfg); err != nil { + return err + } if err := verifyLocalEndpointIdentity(cfg); err != nil { return err } diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index 704bae5bc6..6dab8a191c 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -612,19 +612,20 @@ func TestVerifyLocalEndpointIdentity(t *testing.T) { } func TestAppDevPublishValidate_NoVerifySkipsEndpointGate(t *testing.T) { - // --no-verify bypasses the dev-server verification (endpoint reachability - // and identity match) while the dev.port declaration stays required. - root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) + // --no-verify bypasses the whole dev-server verification: the dev.port + // declaration requirement, endpoint reachability, and identity match. + root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","app":{"id":"app_x"}}`) stubLocalEndpoint(t, "", fmt.Errorf("no dev server reachable")) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/index.html", "output/routes.json"}) factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--no-verify", "--as", "user", "--dry-run"}, factory, stdout); err != nil { t.Fatalf("--no-verify must skip the endpoint gate: %v", err) } - // Without the flag the same state is blocked. + // Without the flag the same state is blocked (here at the declaration + // layer already, since the fixture omits dev.port). err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user", "--dry-run"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryValidation) - if !strings.Contains(p.Message, "unavailable") { + if !strings.Contains(p.Message, "dev.port") { t.Errorf("got %v", p) } } From 102ffc5e105273db186358e962a00d2943b9092d Mon Sep 17 00:00:00 2001 From: duanlikang Date: Sat, 29 Aug 2026 15:54:19 +0800 Subject: [PATCH 42/51] fix(apps): compare the endpoint identity against the resolved deploy target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in a real first-deploy walkthrough: the guard compared against the directory's recorded app.id, which is empty on a first deploy with --app-id — the error printed an empty target and, worse, a fresh project's own dev server (no app.id served yet) would have been rejected. Compare against the resolved target instead, and let an endpoint without an app id pass (the normal first-deploy state). --- shortcuts/apps/apps_deploy.go | 26 ++++++++++++-------------- shortcuts/apps/apps_deploy_test.go | 24 +++++++++--------------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index 25f567114a..c423e31155 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -267,26 +267,24 @@ var appDevProbeLocalEndpoint = probeLocalSparkEndpoint // verifyLocalEndpointIdentity is the hosting entry's enforcement of the // protocol's local self-description endpoint plus the cross-project deploy // guard: the dev server MUST be running and serving /spark.json, and when -// either side declares an app id the two MUST match — a mismatch means the +// the served declaration carries an app id it MUST match the resolved +// deploy target (--app-id or the recorded one) — a mismatch means the // running project is not the one being deployed, which would ship this -// payload onto another project's app. Only the "neither side has an app id -// yet" case skips the comparison (first deploy of a fresh project). -func verifyLocalEndpointIdentity(cfg *appDevProjectConfig) error { +// payload onto another project's app. An endpoint without an app id passes: +// that is the normal state of a fresh project's first deploy. +func verifyLocalEndpointIdentity(cfg *appDevProjectConfig, targetAppID string) error { endpointID, err := appDevProbeLocalEndpoint(cfg.DevPort) if err != nil { return appsFailedPreconditionError("the local self-description endpoint is unavailable: %v", err). WithHint(fmt.Sprintf("start the dev server (spark.json dev.command, port %d) before deploying — the platform requires GET /spark.json to serve the project declaration (official templates ship this endpoint; custom projects must serve the project-root spark.json themselves)", cfg.DevPort)) } - if endpointID == "" && cfg.AppID == "" { + if endpointID == "" || endpointID == targetAppID { return nil } - if endpointID != cfg.AppID { - return appsFailedPreconditionError( - "the dev server on 127.0.0.1:%d declares app %q, but this directory deploys app %q — refusing to ship one project's payload onto another project's app", - cfg.DevPort, endpointID, cfg.AppID). - WithHint("you are likely deploying from the wrong directory (or the wrong dev server is running on this port); deploy from the project that owns the running dev server, or restart the right one") - } - return nil + return appsFailedPreconditionError( + "the dev server on 127.0.0.1:%d declares app %q, but this deploy targets app %q — refusing to ship one project's payload onto another project's app", + cfg.DevPort, endpointID, targetAppID). + WithHint("you are likely deploying from the wrong directory (or the wrong dev server is running on this port); deploy from the project that owns the running dev server, or restart the right one") } // warnMissingIndexHTML reports whether the same-origin payload lacks an @@ -461,7 +459,7 @@ var AppsDeploy = common.Shortcut{ {Name: "no-verify", Type: "bool", Desc: "skip the local dev-server verification entirely (the dev.port declaration requirement, the GET 127.0.0.1:/spark.json availability check, and the app-identity match)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - cfg, _, _, err := resolveAppDevPublishTarget(rctx) + cfg, targetAppID, _, err := resolveAppDevPublishTarget(rctx) if err != nil { return err } @@ -469,7 +467,7 @@ var AppsDeploy = common.Shortcut{ if err := validateSparkDeclaration(cfg); err != nil { return err } - if err := verifyLocalEndpointIdentity(cfg); err != nil { + if err := verifyLocalEndpointIdentity(cfg, targetAppID); err != nil { return err } } diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index 6dab8a191c..7bf3c01d6d 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -558,37 +558,31 @@ func localEndpointServer(t *testing.T, body string, status int) int { } func TestVerifyLocalEndpointIdentity(t *testing.T) { - cfgWith := func(appID string, port int) *appDevProjectConfig { - return &appDevProjectConfig{AppID: appID, DevPort: port} + cfgWith := func(port int) *appDevProjectConfig { + return &appDevProjectConfig{DevPort: port} } t.Run("mismatch is rejected", func(t *testing.T) { port := localEndpointServer(t, `{"stack":"custom-webapp","app":{"id":"app_other"}}`, 200) - err := verifyLocalEndpointIdentity(cfgWith("app_mine", port)) + err := verifyLocalEndpointIdentity(cfgWith(port), "app_mine") if err == nil || !strings.Contains(err.Error(), "app_other") || !strings.Contains(err.Error(), "app_mine") { t.Errorf("mismatch must be rejected naming both ids, got %v", err) } }) t.Run("matching id passes", func(t *testing.T) { port := localEndpointServer(t, `{"app":{"id":"app_mine"}}`, 200) - if err := verifyLocalEndpointIdentity(cfgWith("app_mine", port)); err != nil { + if err := verifyLocalEndpointIdentity(cfgWith(port), "app_mine"); err != nil { t.Errorf("matching identity must pass: %v", err) } }) - t.Run("both without app id pass", func(t *testing.T) { + t.Run("endpoint without app id passes (fresh project first deploy)", func(t *testing.T) { port := localEndpointServer(t, `{"stack":"custom-webapp"}`, 200) - if err := verifyLocalEndpointIdentity(cfgWith("", port)); err != nil { - t.Errorf("fresh project on both sides must pass: %v", err) - } - }) - t.Run("endpoint without app id but deploy dir with one is rejected", func(t *testing.T) { - port := localEndpointServer(t, `{"stack":"custom-webapp"}`, 200) - if err := verifyLocalEndpointIdentity(cfgWith("app_mine", port)); err == nil { - t.Error("one-sided app id must be rejected (served declaration disagrees with the deploy dir)") + if err := verifyLocalEndpointIdentity(cfgWith(port), "app_mine"); err != nil { + t.Errorf("an endpoint without an app id is a fresh project and must pass: %v", err) } }) t.Run("non-json endpoint is rejected", func(t *testing.T) { port := localEndpointServer(t, "not a spark project", 200) - err := verifyLocalEndpointIdentity(cfgWith("app_mine", port)) + err := verifyLocalEndpointIdentity(cfgWith(port), "app_mine") if err == nil || !strings.Contains(err.Error(), "not valid JSON") { t.Errorf("non-JSON endpoint must be rejected: %v", err) } @@ -603,7 +597,7 @@ func TestVerifyLocalEndpointIdentity(t *testing.T) { orig := appDevProbeLocalEndpoint appDevProbeLocalEndpoint = probeLocalSparkEndpoint defer func() { appDevProbeLocalEndpoint = orig }() - gerr := verifyLocalEndpointIdentity(cfgWith("app_mine", port)) + gerr := verifyLocalEndpointIdentity(cfgWith(port), "app_mine") p, _ := errs.ProblemOf(gerr) if p == nil || !strings.Contains(p.Message, "unavailable") || !strings.Contains(p.Hint, "start the dev server") { t.Errorf("missing dev server must hard-fail with start guidance, got %v", gerr) From 3ac01a9f2247d72d11d856208ec54e76cb81aa3f Mon Sep 17 00:00:00 2001 From: duanlikang Date: Sat, 29 Aug 2026 16:07:29 +0800 Subject: [PATCH 43/51] docs(apps): align the identity-check wording with the resolved-target rule --- skills/lark-apps/references/lark-apps-deploy.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/skills/lark-apps/references/lark-apps-deploy.md b/skills/lark-apps/references/lark-apps-deploy.md index b364a29d31..44e75e484a 100644 --- a/skills/lark-apps/references/lark-apps-deploy.md +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -10,7 +10,8 @@ - **必须在项目根目录执行**(项目根须有 `spark.json`,它是唯一的项目声明文件)。同源产物目录取 spark.json 的 `build.output`(缺省 `dist/output`),CDN 产物目录取可选的 `build.output_cdn`(不声明 = 无 CDN 分离),无 `--path` 参数。 - `--app-id` 可选:首次发布传它指定目标(成功后自动写入 `spark.json` 的 app 段,后续免传);已记录 app id 时可省略;**两者都有且不一致会被拒绝**(防误发错目标),确要切换先更新 spark.json。 -- 可选:`--skip-build`(跳过 `build.command`,直接发布已有产物目录)、`--allow-sensitive`(跳过凭据文件扫描)。 +- **发布前须启动本地 dev server**:`+deploy` 会验证 `GET 127.0.0.1:/spark.json` 可达且其 `app.id` 与本次部署目标一致(防止把 A 项目的产物发到 B 应用;端点尚未声明 app.id 时放行——首发项目的正常状态)。无头/CI 环境用 `--no-verify` 显式跳过该验证。 +- 可选:`--skip-build`(跳过 `build.command`,直接发布已有产物目录)、`--no-verify`(整体跳过本地 dev server 验证:dev.port 声明要求、端点可达性、app 身份比对)。 - 内部流程:读 spark.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → 执行 `build.command`(argv 直接执行不走 shell,自动注入变量;**spark.json 未声明 build.command = buildless,跳过构建直接打包**)→ 校验产物协议 → 归一化打包(`build.output` → zip 内 `output/`,`build.output_cdn` → zip 内 `output_resource/`,流水线不感知项目目录名)→ 上传 → 触发发布。 - 产物协议(详见《妙搭产物托管协议规范》):`build.output` 目录必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(**路由枚举数组**,如 `[{"path":"/","file":"index.html"}]`,纯静态站可为空数组;它是安全扫描的输入,必须与真实路由一致);目录内其余静态文件全部随包上传。**buildless 项目缺 routes.json 时由 CLI 扫描 `.html` 文件树自动生成**(`foo/index.html` → `/foo`),工程自带的 routes.json 永不被覆盖。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 @@ -38,14 +39,14 @@ lark-cli apps +deploy --dry-run ## 安全规则 -- 敏感文件扫描命中(`.env`、`.npmrc` 等)时,**不要自动加 `--allow-sensitive` 重试**;把命中的文件列表转述给用户,由用户决定移除还是明确豁免。 - 构建环境变量只注入 `pre_release` 下发的 `MIAODA_*` 白名单键;命令会在 stderr 回显实际注入的键名。 ## 常见失败 - `current directory is not a Miaoda app project`:不在项目根执行;`cd` 到含 `spark.json` 的目录。 -- `spark.json is missing the required stack field` / `stack ... must end with -webapp or -fullstack`:声明技术栈——官方模板自动写入;自定义项目填 `custom-webapp` / `custom-fullstack`。 - `spark.json is missing the required dev.port field`:声明本地 dev 端口(如 `{"dev":{"port":5173}}`)——托管后平台能力依托本地自描述端点(`GET localhost:/spark.json`),必填。 +- `the local self-description endpoint is unavailable`:先启动 dev server(官方模板已内置 /spark.json 端点;custom 项目须自己伺服项目根 spark.json);无头/CI 环境用 `--no-verify`——**不要因为端点验证失败就自动加 `--no-verify` 重试**,先确认是环境问题而非发错目录。 +- `the dev server ... declares app X, but this directory deploys app Y`:**大概率发错目录**——停下核对当前目录与正在运行的 dev server 是否同一项目,把情况告知用户,不要用 `--no-verify` 绕过。 - `warning: no index.html ...`:不拦截但强烈建议修复——平台 SPA fallback 依赖入口 index.html,缺失时线上路由回退会异常。 - `routes.json is missing` / schema 校验失败:声明了 `build.command` 的项目由构建脚本负责生成合法 routes.json;让用户检查构建配置,不要手工伪造(buildless 项目无此问题,CLI 会自动生成)。 - `build command ... failed`:转述 stderr 摘要让用户修构建错误(构建命令来自 spark.json `build.command`);用户已手动构建时可用 `--skip-build`。 From e53240d5ab442b2a55c8f73acbdd5f6bd6d173d2 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Sat, 29 Aug 2026 19:53:46 +0800 Subject: [PATCH 44/51] fix(apps): probe the self-description endpoint via localhost for dual-stack reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite's default localhost bind often lands on ::1 only (Node >= 17), so a literal 127.0.0.1 probe gets connection-refused while the dev server is actually up — agents then work around it by rebinding to 0.0.0.0, which needlessly exposes the dev server. Dialing "localhost" lets the dialer try both loopback families; either bind now passes verification. --- shortcuts/apps/apps_deploy.go | 25 +++++++++++-------- shortcuts/apps/apps_deploy_test.go | 25 +++++++++++++++++++ .../lark-apps/references/lark-apps-deploy.md | 2 +- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index c423e31155..8b63c38baa 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -230,23 +230,26 @@ func validateSparkDeclaration(cfg *appDevProjectConfig) error { var appDevEndpointProbeTimeout = 2 * time.Second // probeLocalSparkEndpoint fetches the protocol's local self-description -// endpoint (GET 127.0.0.1:/spark.json) and returns the app id it -// declares ("" when the served declaration carries none). Any failure to -// reach a valid endpoint — no listener, non-200, unreadable body, invalid -// JSON — comes back as an error naming the reason. +// endpoint (GET localhost:/spark.json) and returns the app id it +// declares ("" when the served declaration carries none). The host is +// literally "localhost" so the dialer's dual-stack resolution reaches dev +// servers bound to either 127.0.0.1 or ::1 (Vite's default localhost bind +// often lands on ::1 only). Any failure to reach a valid endpoint — no +// listener, non-200, unreadable body, invalid JSON — comes back as an +// error naming the reason. func probeLocalSparkEndpoint(port int) (appID string, err error) { client := &http.Client{Timeout: appDevEndpointProbeTimeout} //nolint:forbidigo // loopback probe of the project's own dev server; not a Lark API call. - resp, gerr := client.Get(fmt.Sprintf("http://127.0.0.1:%d/spark.json", port)) //nolint:forbidigo // loopback probe of the project's own dev server; not a Lark API call. + resp, gerr := client.Get(fmt.Sprintf("http://localhost:%d/spark.json", port)) //nolint:forbidigo // loopback probe of the project's own dev server; not a Lark API call. if gerr != nil { - return "", fmt.Errorf("no dev server reachable on 127.0.0.1:%d", port) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. + return "", fmt.Errorf("no dev server reachable on localhost:%d", port) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GET 127.0.0.1:%d/spark.json returned HTTP %d", port, resp.StatusCode) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. + return "", fmt.Errorf("GET localhost:%d/spark.json returned HTTP %d", port, resp.StatusCode) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. } body, rerr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if rerr != nil { - return "", fmt.Errorf("reading 127.0.0.1:%d/spark.json failed: %w", port, rerr) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. + return "", fmt.Errorf("reading localhost:%d/spark.json failed: %w", port, rerr) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. } var doc struct { App struct { @@ -254,7 +257,7 @@ func probeLocalSparkEndpoint(port int) (appID string, err error) { } `json:"app"` } if json.Unmarshal(body, &doc) != nil { - return "", fmt.Errorf("127.0.0.1:%d/spark.json is not valid JSON", port) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. + return "", fmt.Errorf("localhost:%d/spark.json is not valid JSON", port) //nolint:forbidigo // intermediate reason; wrapped into a typed error by the caller. } return strings.TrimSpace(doc.App.ID), nil } @@ -282,7 +285,7 @@ func verifyLocalEndpointIdentity(cfg *appDevProjectConfig, targetAppID string) e return nil } return appsFailedPreconditionError( - "the dev server on 127.0.0.1:%d declares app %q, but this deploy targets app %q — refusing to ship one project's payload onto another project's app", + "the dev server on localhost:%d declares app %q, but this deploy targets app %q — refusing to ship one project's payload onto another project's app", cfg.DevPort, endpointID, targetAppID). WithHint("you are likely deploying from the wrong directory (or the wrong dev server is running on this port); deploy from the project that owns the running dev server, or restart the right one") } @@ -456,7 +459,7 @@ var AppsDeploy = common.Shortcut{ Flags: []common.Flag{ {Name: "app-id", Desc: "publish target app ID (app_ prefix); optional when spark.json already records one — on a successful publish it is saved back into spark.json, and a value conflicting with the recorded one is rejected"}, {Name: "skip-build", Type: "bool", Desc: "skip the build.command declared in spark.json and publish the existing build.output directory as-is (no effect on buildless projects, which never build)"}, - {Name: "no-verify", Type: "bool", Desc: "skip the local dev-server verification entirely (the dev.port declaration requirement, the GET 127.0.0.1:/spark.json availability check, and the app-identity match)"}, + {Name: "no-verify", Type: "bool", Desc: "skip the local dev-server verification entirely (the dev.port declaration requirement, the GET localhost:/spark.json availability check, and the app-identity match)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { cfg, targetAppID, _, err := resolveAppDevPublishTarget(rctx) diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index 7bf3c01d6d..b2bb315964 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -557,6 +557,31 @@ func localEndpointServer(t *testing.T, body string, status int) int { return srv.Listener.Addr().(*net.TCPAddr).Port } +func TestProbeLocalSparkEndpoint_IPv6OnlyBind(t *testing.T) { + // Vite's default localhost bind often lands on ::1 only (Node >= 17). + // Probing "localhost" must reach such a server via dual-stack dialing. + l, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skipf("IPv6 loopback unavailable: %v", err) + } + srv := &httptest.Server{ + Listener: l, + Config: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"app":{"id":"app_v6"}}`)) + })}, + } + srv.Start() + t.Cleanup(srv.Close) + port := l.Addr().(*net.TCPAddr).Port + id, err := probeLocalSparkEndpoint(port) + if err != nil { + t.Fatalf("probe must reach an IPv6-only dev server via localhost: %v", err) + } + if id != "app_v6" { + t.Errorf("got app id %q, want app_v6", id) + } +} + func TestVerifyLocalEndpointIdentity(t *testing.T) { cfgWith := func(port int) *appDevProjectConfig { return &appDevProjectConfig{DevPort: port} diff --git a/skills/lark-apps/references/lark-apps-deploy.md b/skills/lark-apps/references/lark-apps-deploy.md index 44e75e484a..c34ed633f1 100644 --- a/skills/lark-apps/references/lark-apps-deploy.md +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -10,7 +10,7 @@ - **必须在项目根目录执行**(项目根须有 `spark.json`,它是唯一的项目声明文件)。同源产物目录取 spark.json 的 `build.output`(缺省 `dist/output`),CDN 产物目录取可选的 `build.output_cdn`(不声明 = 无 CDN 分离),无 `--path` 参数。 - `--app-id` 可选:首次发布传它指定目标(成功后自动写入 `spark.json` 的 app 段,后续免传);已记录 app id 时可省略;**两者都有且不一致会被拒绝**(防误发错目标),确要切换先更新 spark.json。 -- **发布前须启动本地 dev server**:`+deploy` 会验证 `GET 127.0.0.1:/spark.json` 可达且其 `app.id` 与本次部署目标一致(防止把 A 项目的产物发到 B 应用;端点尚未声明 app.id 时放行——首发项目的正常状态)。无头/CI 环境用 `--no-verify` 显式跳过该验证。 +- **发布前须启动本地 dev server**:`+deploy` 会验证 `GET localhost:/spark.json` 可达(localhost 双栈解析,dev server 绑 `127.0.0.1` 或 `::1` 均可)且其 `app.id` 与本次部署目标一致(防止把 A 项目的产物发到 B 应用;端点尚未声明 app.id 时放行——首发项目的正常状态)。无头/CI 环境用 `--no-verify` 显式跳过该验证。 - 可选:`--skip-build`(跳过 `build.command`,直接发布已有产物目录)、`--no-verify`(整体跳过本地 dev server 验证:dev.port 声明要求、端点可达性、app 身份比对)。 - 内部流程:读 spark.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → 执行 `build.command`(argv 直接执行不走 shell,自动注入变量;**spark.json 未声明 build.command = buildless,跳过构建直接打包**)→ 校验产物协议 → 归一化打包(`build.output` → zip 内 `output/`,`build.output_cdn` → zip 内 `output_resource/`,流水线不感知项目目录名)→ 上传 → 触发发布。 - 产物协议(详见《妙搭产物托管协议规范》):`build.output` 目录必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(**路由枚举数组**,如 `[{"path":"/","file":"index.html"}]`,纯静态站可为空数组;它是安全扫描的输入,必须与真实路由一致);目录内其余静态文件全部随包上传。**buildless 项目缺 routes.json 时由 CLI 扫描 `.html` 文件树自动生成**(`foo/index.html` → `/foo`),工程自带的 routes.json 永不被覆盖。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 From b1146d37e7ef1f63e4058d4ac6331a9b3eed5297 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Mon, 31 Aug 2026 14:36:28 +0800 Subject: [PATCH 45/51] polish(apps): keep routes.json guidance self-contained in comments and hints --- shortcuts/apps/apps_deploy.go | 12 ++++++------ shortcuts/apps/apps_spark_config.go | 2 +- skills/lark-apps/references/lark-apps-deploy.md | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index 8b63c38baa..346dbd937d 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -172,20 +172,20 @@ func generateAppDevRoutes(htmlRels []string) (data []byte, count int, err error) return b, len(routes), nil } -// appDevRoute is one entry of the routes.json route enumeration consumed by -// TNS security scanning: path is required (leading /, no base prefix, may -// hold :param segments); file/name are optional; unknown fields are ignored -// for forward compatibility. +// appDevRoute is one entry of the routes.json route enumeration the platform +// consumes: path is required (leading /, no base prefix, may hold :param +// segments); file/name are optional; unknown fields are ignored for forward +// compatibility. type appDevRoute struct { Path string `json:"path"` } // appDevRoutesHint is the actionable schema reminder for routes.json errors. -const appDevRoutesHint = `routes.json must be a route enumeration array, e.g. [{"path":"/","file":"index.html"}] (empty [] is allowed for a static site); it feeds security scanning, so it must match the real routes` +const appDevRoutesHint = `routes.json must be a route enumeration array, e.g. [{"path":"/","file":"index.html"}] (empty [] is allowed for a static site); it must enumerate the app's real routes` // validateAppDevRoutesJSON light-checks a routes.json payload against the // route-enumeration schema so problems fail at publish time instead of -// bouncing off the TNS scan later: top level must be an array, every entry +// being rejected server-side later: top level must be an array, every entry // needs a /-prefixed path, and paths must be unique. func validateAppDevRoutesJSON(b []byte) error { var routes []appDevRoute diff --git a/shortcuts/apps/apps_spark_config.go b/shortcuts/apps/apps_spark_config.go index 4c962b2184..ac1fd48f60 100644 --- a/shortcuts/apps/apps_spark_config.go +++ b/shortcuts/apps/apps_spark_config.go @@ -14,7 +14,7 @@ import ( ) // sparkJSONRelPath is the project declaration file of the artifact-hosting -// protocol (妙搭产物托管协议规范 §3): how to dev/build, plus the app state +// protocol declaration: how to dev/build, plus the app state // section written back by the deploy chain. const sparkJSONRelPath = "spark.json" diff --git a/skills/lark-apps/references/lark-apps-deploy.md b/skills/lark-apps/references/lark-apps-deploy.md index c34ed633f1..ac54cbb090 100644 --- a/skills/lark-apps/references/lark-apps-deploy.md +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -13,7 +13,7 @@ - **发布前须启动本地 dev server**:`+deploy` 会验证 `GET localhost:/spark.json` 可达(localhost 双栈解析,dev server 绑 `127.0.0.1` 或 `::1` 均可)且其 `app.id` 与本次部署目标一致(防止把 A 项目的产物发到 B 应用;端点尚未声明 app.id 时放行——首发项目的正常状态)。无头/CI 环境用 `--no-verify` 显式跳过该验证。 - 可选:`--skip-build`(跳过 `build.command`,直接发布已有产物目录)、`--no-verify`(整体跳过本地 dev server 验证:dev.port 声明要求、端点可达性、app 身份比对)。 - 内部流程:读 spark.json → `pre_release` 获取上传地址与 `MIAODA_*` 构建环境变量 → 执行 `build.command`(argv 直接执行不走 shell,自动注入变量;**spark.json 未声明 build.command = buildless,跳过构建直接打包**)→ 校验产物协议 → 归一化打包(`build.output` → zip 内 `output/`,`build.output_cdn` → zip 内 `output_resource/`,流水线不感知项目目录名)→ 上传 → 触发发布。 -- 产物协议(详见《妙搭产物托管协议规范》):`build.output` 目录必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(**路由枚举数组**,如 `[{"path":"/","file":"index.html"}]`,纯静态站可为空数组;它是安全扫描的输入,必须与真实路由一致);目录内其余静态文件全部随包上传。**buildless 项目缺 routes.json 时由 CLI 扫描 `.html` 文件树自动生成**(`foo/index.html` → `/foo`),工程自带的 routes.json 永不被覆盖。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 +- 产物协议:`build.output` 目录必须含 ≥1 个 `.html`(SPA 入口须名 `index.html`)与合法的 `routes.json`(**路由枚举数组**,如 `[{"path":"/","file":"index.html"}]`,纯静态站可为空数组;必须与应用真实路由一致);目录内其余静态文件全部随包上传。**buildless 项目缺 routes.json 时由 CLI 扫描 `.html` 文件树自动生成**(`foo/index.html` → `/foo`),工程自带的 routes.json 永不被覆盖。包体限制:zip ≤ 50MB、未压缩总量 ≤ 200MB。 ## 示例 From 93ce9b444ab44fbfc4260ebd81f14733f538fed0 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Mon, 31 Aug 2026 14:46:13 +0800 Subject: [PATCH 46/51] polish(apps): tidy protocol references in comments and hints --- shortcuts/apps/apps_deploy.go | 8 ++++---- shortcuts/apps/apps_deploy_test.go | 2 +- shortcuts/apps/apps_spark_config.go | 13 +++++++------ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index 346dbd937d..f61d531756 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -137,7 +137,7 @@ func validateAppDevOutputs(fio fileio.FileIO, cfg *appDevProjectConfig) (entries entries = append(entries, appDevPackEntry{ZipPath: "output/routes.json", Content: b, Size: int64(len(b))}) default: return nil, -1, appsFailedPreconditionError("%s/routes.json is missing", cfg.BuildOutput). - WithHint("routes.json is required for content review routing; a declared build.command is expected to produce it (official templates generate it during the build)") + WithHint("routes.json is required by the hosting protocol and must enumerate the app's real routes; a declared build.command is expected to produce it (official templates generate it during the build)") } return entries, generatedRoutes, nil } @@ -294,7 +294,7 @@ func verifyLocalEndpointIdentity(cfg *appDevProjectConfig, targetAppID string) e // output/index.html entry. The platform gateway's SPA fallback serves the // entry HTML for unmatched paths, so publishing without one is almost // always a broken build — kept as a warning (not a gate) per the protocol -// owner's call. +// decision. func warnMissingIndexHTML(entries []appDevPackEntry) bool { for _, e := range entries { if e.ZipPath == "output/index.html" { @@ -673,8 +673,8 @@ var AppsDeploy = common.Shortcut{ pollHint = fmt.Sprintf("lark-cli apps +release-get --app-id %s --release-id %s", appID, releaseID) data["poll_hint"] = pollHint } - // The release was accepted — write the app state back per protocol - // (§3): spark.json gets the app section replaced wholesale. + // The release was accepted — write the app state back per protocol: + // spark.json gets the app section replaced wholesale. // Best-effort: a write failure must not fail the publish. if err := writeSparkAppSection(".", appID, onlineURL); err != nil { fmt.Fprintf(rctx.IO().ErrOut, "warning: failed to write app state into %s: %v\n", sparkJSONRelPath, err) diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index b2bb315964..232c643fcc 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -503,7 +503,7 @@ func TestAppDevPublishValidate_Declaration(t *testing.T) { func TestAppDevPublishExecute_MissingIndexHTMLWarns(t *testing.T) { // A payload without index.html publishes (warning only, per the protocol - // owner's call) — the platform's SPA fallback depends on it, so the + // decision) — the platform's SPA fallback depends on it, so the // warning must be loud but non-blocking. root := chdirSparkProjectRoot(t, `{"stack":"custom-webapp","dev":{"port":5173},"app":{"id":"app_x"}}`) writeDistFiles(t, filepath.Join(root, "dist"), []string{"output/page.html", "output/routes.json"}) diff --git a/shortcuts/apps/apps_spark_config.go b/shortcuts/apps/apps_spark_config.go index ac1fd48f60..260388bfc5 100644 --- a/shortcuts/apps/apps_spark_config.go +++ b/shortcuts/apps/apps_spark_config.go @@ -19,7 +19,7 @@ import ( const sparkJSONRelPath = "spark.json" // appDevDefaultBuildOutput is the protocol default for build.output (the -// same-origin artifact directory). Since protocol v0.3 there is no default +// same-origin artifact directory). The protocol defines no default // build command: a missing build.command means buildless (pack the output // directory as-is). const appDevDefaultBuildOutput = "dist/output" @@ -47,7 +47,7 @@ type appDevProjectConfig struct { AppURL string } -// sparkJSONDoc mirrors the spark.json schema (§3). Unknown fields are +// sparkJSONDoc mirrors the spark.json declaration schema. Unknown fields are // ignored on read and preserved on write (the writer re-marshals the raw // map, not this struct). type sparkJSONDoc struct { @@ -97,9 +97,9 @@ func readAppDevProjectConfig(dir string) (cfg *appDevProjectConfig, found bool, return cfg, true, nil } -// applyAppDevConfigDefaults fills protocol defaults (§3): build.output → +// applyAppDevConfigDefaults fills the protocol defaults: build.output → // dist/output. build.command deliberately has no default — missing means -// buildless (§4), and build.output_cdn stays empty for Level 1. +// buildless, and build.output_cdn stays empty when undeclared. func applyAppDevConfigDefaults(cfg *appDevProjectConfig) { if cfg.BuildOutput == "" { cfg.BuildOutput = appDevDefaultBuildOutput @@ -111,7 +111,7 @@ func applyAppDevConfigDefaults(cfg *appDevProjectConfig) { func (c *appDevProjectConfig) Buildless() bool { return len(c.BuildCommand) == 0 } // writeSparkAppSection replaces the app state section of /spark.json -// with {id, online_url} after a successful publish (§3: the app section is owned by +// with {id, online_url} after a successful publish (the app section is owned by // the deploy chain and replaced wholesale; declaration fields are never // touched). Empty online_url omits the key. Creates the file if missing. func writeSparkAppSection(dir, appID, appURL string) error { @@ -162,7 +162,8 @@ func syncSparkAppURL(rctx *common.RuntimeContext, appID, onlineURL string) { // /spark.json after template rendering: version is always stamped with // the rendered package version (authoritative), stack is only filled when the // template seed did not declare one, and every other field the seed shipped -// (dev/build declarations, unknown fields) is preserved (§3 字段所有权). +// (dev/build declarations, unknown fields) is preserved (field ownership +// stays with the project). func writeSparkScaffoldFields(dir, stack, version string) error { path := filepath.Join(dir, sparkJSONRelPath) doc := map[string]interface{}{} From 700b118cb5ab45004a01af0ab6661b8a0ef17bb8 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Mon, 31 Aug 2026 14:53:55 +0800 Subject: [PATCH 47/51] polish(apps): drop a stray section reference from a test comment --- shortcuts/apps/apps_init_template_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/apps/apps_init_template_test.go b/shortcuts/apps/apps_init_template_test.go index 9e4c64892d..3aad3b7ee7 100644 --- a/shortcuts/apps/apps_init_template_test.go +++ b/shortcuts/apps/apps_init_template_test.go @@ -653,7 +653,7 @@ func TestAppDevInitTemplateExecute_RendersFromRegistry(t *testing.T) { if err != nil || !strings.Contains(string(b), dir) { t.Errorf("index.html placeholder = %q err=%v (projectName is dir basename)", b, err) } - // spark.json written by lark-cli (protocol §3). + // spark.json written by lark-cli per the hosting protocol. mb, err := os.ReadFile(filepath.Join(dir, sparkJSONRelPath)) if err != nil { t.Fatal(err) From 1a876bbac80bda5a030de6404eda3488b68bb45d Mon Sep 17 00:00:00 2001 From: duanlikang Date: Mon, 31 Aug 2026 15:30:47 +0800 Subject: [PATCH 48/51] test(apps): cover error paths and swap test hostnames to RFC 2606 names Raises the patch's weakest spots: the real exec runner (previously only the fake was exercised), spark.json write/sync error branches, registry HTTP error branches, and the zip missing-source path. Test fixtures now use RFC 2606 reserved names so the source-contract domain guard passes. --- shortcuts/apps/apps_deploy_test.go | 24 ++- shortcuts/apps/apps_deploy_zip_test.go | 9 + shortcuts/apps/apps_init_template_test.go | 32 ++- shortcuts/apps/apps_spark_config_test.go | 230 ++++++++++++++++++++++ 4 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 shortcuts/apps/apps_spark_config_test.go diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index 232c643fcc..5167cca90f 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -714,7 +714,7 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { }) stubReleases(reg, "app_x", map[string]interface{}{ "release_id": "rel_1", "status": "finished", - "online_url": "https://x.feishuapp.cn/app/app_x", + "online_url": "https://apps.example/app/app_x", }) if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("unexpected: %v", err) @@ -735,7 +735,7 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { } // Output contract. data := parseEnvelopeData(t, stdout) - if data["online_url"] != "https://x.feishuapp.cn/app/app_x" || data["release_id"] != "rel_1" { + if data["online_url"] != "https://apps.example/app/app_x" || data["release_id"] != "rel_1" { t.Errorf("data = %v", data) } if data["built"] != true { @@ -749,7 +749,7 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { var doc map[string]interface{} _ = json.Unmarshal(b, &doc) app, _ := doc["app"].(map[string]interface{}) - if app == nil || app["id"] != "app_x" || app["online_url"] != "https://x.feishuapp.cn/app/app_x" { + if app == nil || app["id"] != "app_x" || app["online_url"] != "https://apps.example/app/app_x" { t.Errorf("app section after publish = %v", doc["app"]) } } @@ -1088,3 +1088,21 @@ func TestAppsDeploy_Declaration(t *testing.T) { t.Errorf("Scopes = %v", AppsDeploy.Scopes) } } + +// --- real exec runner --- + +func TestExecEnvCommandRunner(t *testing.T) { + dir := t.TempDir() + stdout, stderr, err := execEnvCommandRunner{}.RunEnv(context.Background(), dir, + []string{"APP_DEV_TEST_FOO=bar"}, "sh", "-c", `printf '%s' "$APP_DEV_TEST_FOO"; printf 'oops' 1>&2`) + if err != nil || stdout != "bar" || stderr != "oops" { + t.Errorf("RunEnv = (%q, %q, %v), want (bar, oops, nil)", stdout, stderr, err) + } + // Empty dir means "inherit the process cwd" (the cmd.Dir branch is skipped). + if _, _, err := (execEnvCommandRunner{}).RunEnv(context.Background(), "", nil, "sh", "-c", "true"); err != nil { + t.Errorf("empty dir must run in the inherited cwd: %v", err) + } + if _, _, err := (execEnvCommandRunner{}).RunEnv(context.Background(), "", nil, "sh", "-c", "exit 3"); err == nil { + t.Error("a failing command must surface its error") + } +} diff --git a/shortcuts/apps/apps_deploy_zip_test.go b/shortcuts/apps/apps_deploy_zip_test.go index 28f01d9151..344369e100 100644 --- a/shortcuts/apps/apps_deploy_zip_test.go +++ b/shortcuts/apps/apps_deploy_zip_test.go @@ -22,3 +22,12 @@ func zipEntryNames(t *testing.T, body []byte) []string { } return names } + +func TestBuildAppDevZip_MissingSourceFile(t *testing.T) { + _, err := buildAppDevZip(permissiveFIO{}, []appDevPackEntry{ + {ZipPath: "output/gone.html", AbsPath: "/nonexistent/gone.html", Size: 1}, + }) + if err == nil { + t.Fatal("an entry whose source file vanished must fail the pack") + } +} diff --git a/shortcuts/apps/apps_init_template_test.go b/shortcuts/apps/apps_init_template_test.go index 3aad3b7ee7..45c05b29bd 100644 --- a/shortcuts/apps/apps_init_template_test.go +++ b/shortcuts/apps/apps_init_template_test.go @@ -551,8 +551,8 @@ func TestResolveAppDevRegistries(t *testing.T) { t.Errorf("unset = (%v, %v), want (nil, nil)", regs, err) } // Explicit https URL: single entry, trailing slash trimmed. - regs, err = resolveAppDevRegistries(rctxWith("https://bnpm.example.com/")) - if err != nil || len(regs) != 1 || regs[0] != "https://bnpm.example.com" { + regs, err = resolveAppDevRegistries(rctxWith("https://bnpm.example/")) + if err != nil || len(regs) != 1 || regs[0] != "https://bnpm.example" { t.Errorf("explicit = (%v, %v)", regs, err) } // http and bare hosts are rejected. @@ -795,3 +795,31 @@ func TestAppDevInitTemplateDryRun_DirNotEmptySurfaced(t *testing.T) { t.Errorf("target_dir_state = %q, want non-empty dir surfaced", state) } } + +// --- registry HTTP error branches --- + +func TestAppDevHTTPGet_ErrorPaths(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/missing", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }) + mux.HandleFunc("/boom", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) }) + mux.HandleFunc("/denied", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) }) + mux.HandleFunc("/big", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(bytes.Repeat([]byte("x"), 64)) }) + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + orig := appDevNewTransferClient + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevNewTransferClient = orig }) + + if _, err := appDevHTTPGet(context.Background(), srv.URL+"/missing", 1024, "check the template name"); err == nil || !strings.Contains(err.Error(), "404") { + t.Errorf("404: err = %v", err) + } + if _, err := appDevHTTPGet(context.Background(), srv.URL+"/boom", 1024, ""); err == nil || !strings.Contains(err.Error(), "502") { + t.Errorf("5xx: err = %v", err) + } + if _, err := appDevHTTPGet(context.Background(), srv.URL+"/denied", 1024, ""); err == nil || !strings.Contains(err.Error(), "403") { + t.Errorf("4xx: err = %v", err) + } + if _, err := appDevHTTPGet(context.Background(), srv.URL+"/big", 16, ""); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Errorf("size cap: err = %v", err) + } +} diff --git a/shortcuts/apps/apps_spark_config_test.go b/shortcuts/apps/apps_spark_config_test.go new file mode 100644 index 0000000000..4f11288f61 --- /dev/null +++ b/shortcuts/apps/apps_spark_config_test.go @@ -0,0 +1,230 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" +) + +// bytesRctx bundles a RuntimeContext with its captured stderr for tests that +// exercise best-effort side effects announced on stderr. +type bytesRctx struct { + rctx *common.RuntimeContext + stderr *bytes.Buffer +} + +func newBytesRctx(t *testing.T) *bytesRctx { + t.Helper() + cfg := &core.CliConfig{AppID: "test-app", AppSecret: "s", Brand: core.BrandFeishu, UserOpenId: "ou_t"} + factory, _, stderrBuf, _ := cmdutil.TestFactory(t, cfg) + cmd := &cobra.Command{Use: "test-sync"} + cmd.SetContext(context.Background()) + return &bytesRctx{ + rctx: common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser), + stderr: stderrBuf, + } +} + +func readJSONFile(t *testing.T, path string) map[string]interface{} { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + doc := map[string]interface{}{} + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatalf("%s is not valid JSON: %v", path, err) + } + return doc +} + +func TestWriteSparkAppSection(t *testing.T) { + t.Run("creates the file when missing", func(t *testing.T) { + dir := t.TempDir() + if err := writeSparkAppSection(dir, "app_new", "https://apps.example/app/app_new"); err != nil { + t.Fatal(err) + } + doc := readJSONFile(t, filepath.Join(dir, "spark.json")) + app, _ := doc["app"].(map[string]interface{}) + if app["id"] != "app_new" || app["online_url"] != "https://apps.example/app/app_new" { + t.Errorf("app section = %v", app) + } + }) + t.Run("empty url omits the key", func(t *testing.T) { + dir := t.TempDir() + if err := writeSparkAppSection(dir, "app_x", ""); err != nil { + t.Fatal(err) + } + app, _ := readJSONFile(t, filepath.Join(dir, "spark.json"))["app"].(map[string]interface{}) + if _, has := app["online_url"]; has || app["id"] != "app_x" { + t.Errorf("app section = %v, want id only", app) + } + }) + t.Run("preserves declaration and unknown fields", func(t *testing.T) { + dir := t.TempDir() + seed := `{"stack":"custom-webapp","dev":{"port":5173},"future_field":42,"app":{"id":"app_old","online_url":"https://apps.example/old"}}` + if err := os.WriteFile(filepath.Join(dir, "spark.json"), []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + if err := writeSparkAppSection(dir, "app_new", "https://apps.example/new"); err != nil { + t.Fatal(err) + } + doc := readJSONFile(t, filepath.Join(dir, "spark.json")) + if doc["stack"] != "custom-webapp" || doc["future_field"] != float64(42) { + t.Errorf("declaration/unknown fields must survive: %v", doc) + } + app, _ := doc["app"].(map[string]interface{}) + if app["id"] != "app_new" || app["online_url"] != "https://apps.example/new" { + t.Errorf("app section must be replaced wholesale: %v", app) + } + }) + t.Run("broken json is an error", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "spark.json"), []byte("{broken"), 0o644); err != nil { + t.Fatal(err) + } + if err := writeSparkAppSection(dir, "app_x", "u"); err == nil || !strings.Contains(err.Error(), "parse") { + t.Errorf("want parse error, got %v", err) + } + }) +} + +func TestWriteSparkScaffoldFields(t *testing.T) { + t.Run("seed stack wins, version always stamped", func(t *testing.T) { + dir := t.TempDir() + seed := `{"stack":"seed-webapp","version":"0.0.1","dev":{"port":5173}}` + if err := os.WriteFile(filepath.Join(dir, "spark.json"), []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + if err := writeSparkScaffoldFields(dir, "cli-derived-webapp", "1.2.3"); err != nil { + t.Fatal(err) + } + doc := readJSONFile(t, filepath.Join(dir, "spark.json")) + if doc["stack"] != "seed-webapp" { + t.Errorf("seed-declared stack must not be overwritten: %v", doc["stack"]) + } + if doc["version"] != "1.2.3" { + t.Errorf("version must be stamped with the rendered package version: %v", doc["version"]) + } + }) + t.Run("broken json is an error", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "spark.json"), []byte("["), 0o644); err != nil { + t.Fatal(err) + } + if err := writeSparkScaffoldFields(dir, "s", "1.0.0"); err == nil { + t.Error("want parse error") + } + }) +} + +func TestReadAppDevProjectConfig_BrokenJSON(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "spark.json"), []byte("{oops"), 0o644); err != nil { + t.Fatal(err) + } + _, found, err := readAppDevProjectConfig(dir) + if !found || err == nil { + t.Errorf("broken file must report found=true with a parse error, got found=%v err=%v", found, err) + } +} + +func TestSyncSparkAppURL(t *testing.T) { + chdir := func(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + } + newRctx := func(t *testing.T) *bytesRctx { return newBytesRctx(t) } + + t.Run("matching project syncs and is idempotent", func(t *testing.T) { + dir := t.TempDir() + seed := `{"stack":"custom-webapp","app":{"id":"app_m"}}` + if err := os.WriteFile(filepath.Join(dir, "spark.json"), []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + chdir(t, dir) + r := newRctx(t) + syncSparkAppURL(r.rctx, "app_m", "https://apps.example/app/app_m") + doc := readJSONFile(t, filepath.Join(dir, "spark.json")) + app, _ := doc["app"].(map[string]interface{}) + if app["online_url"] != "https://apps.example/app/app_m" { + t.Fatalf("url must be synced, got %v", app) + } + if !strings.Contains(r.stderr.String(), "synced into") { + t.Errorf("stderr must announce the sync, got %q", r.stderr.String()) + } + // Second call with the same url must be a silent no-op. + r.stderr.Reset() + syncSparkAppURL(r.rctx, "app_m", "https://apps.example/app/app_m") + if r.stderr.Len() != 0 { + t.Errorf("already-synced url must skip silently, stderr=%q", r.stderr.String()) + } + }) + t.Run("skips silently outside a project and on id mismatch", func(t *testing.T) { + empty := t.TempDir() + chdir(t, empty) + r := newRctx(t) + syncSparkAppURL(r.rctx, "app_x", "u") // no spark.json + if _, err := os.Stat(filepath.Join(empty, "spark.json")); !os.IsNotExist(err) { + t.Error("no file must be created outside a project") + } + + other := t.TempDir() + if err := os.WriteFile(filepath.Join(other, "spark.json"), []byte(`{"app":{"id":"app_other"}}`), 0o644); err != nil { + t.Fatal(err) + } + chdir(t, other) + syncSparkAppURL(r.rctx, "app_x", "https://apps.example/u") + app, _ := readJSONFile(t, filepath.Join(other, "spark.json"))["app"].(map[string]interface{}) + if _, has := app["online_url"]; has { + t.Error("a different recorded app id must not be touched") + } + }) + t.Run("write failure only warns on stderr", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "spark.json"), []byte(`{"app":{"id":"app_ro"}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(dir, "spark.json"), 0o444); err != nil { + t.Fatal(err) + } + chdir(t, dir) + r := newRctx(t) + syncSparkAppURL(r.rctx, "app_ro", "https://apps.example/app/app_ro") + if !strings.Contains(r.stderr.String(), "warning: failed to sync") { + t.Errorf("write failure must warn on stderr, got %q", r.stderr.String()) + } + }) + t.Run("skips silently on a broken declaration", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "spark.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + chdir(t, dir) + r := newRctx(t) + syncSparkAppURL(r.rctx, "app_x", "u") + if b, _ := os.ReadFile(filepath.Join(dir, "spark.json")); string(b) != "{bad" { + t.Error("a broken file must be left untouched") + } + }) +} From f4d41bbd933cf06be217c40a8d9d60f59cd8c577 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Mon, 31 Aug 2026 15:35:20 +0800 Subject: [PATCH 49/51] fix(apps): route the new local-dev commands in SKILL.md; polish deploy errors The domain skill's routing table never mentioned +init-template/+deploy, so an agent loading the skill could not discover the new chain. Also name the actual pre_release key in the https guard message and hint at presigned-URL expiry on a non-retryable upload rejection. --- shortcuts/apps/apps_deploy.go | 5 +++-- skills/lark-apps/SKILL.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index f61d531756..2e600f373a 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -567,7 +567,7 @@ var AppsDeploy = common.Shortcut{ return appsSubprocessEnvelopeError("pre_release kvs missing %s", appDevUploadURLKey) } if u, perr := url.Parse(uploadURL); perr != nil || u.Scheme != "https" { - return appsSubprocessEnvelopeError("pre_release upload_url is not https; refusing to upload") + return appsSubprocessEnvelopeError("pre_release %s is not https; refusing to upload", appDevUploadURLKey) } built := false @@ -621,7 +621,8 @@ var AppsDeploy = common.Shortcut{ if resp.StatusCode >= 500 { return errs.NewNetworkError(errs.SubtypeNetworkServer, "TOS upload failed: HTTP %d", resp.StatusCode).WithRetryable() } - return errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed: HTTP %d", resp.StatusCode) + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed: HTTP %d", resp.StatusCode). + WithHint("a presigned upload URL can expire while a long build runs; re-run the deploy (add --skip-build to reuse the artifacts just built)") } // The artifact-hosting release needs no body: the artifact location is diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 58a4b1776b..9f7b9b9e61 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -33,6 +33,7 @@ lark-cli auth login --domain apps | 查单个应用详情(类型、名称、发布状态等) | `+get --app-id ` | [`lark-apps-get.md`](references/lark-apps-get.md) | | 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) | | HTML 应用 / 创意模式 — 写 HTML 页面/网站、静态页、PPT/deck、落地页、仪表盘、UI mockup、原型、线框图、视觉探索 | 加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) | [`creative-design/creative-design.md`](creative-design/creative-design.md) | +| 本地开发 Web 应用并托管产物(脚手架新项目 / 项目根有 `spark.json` 的产物托管项目要构建发布) | `+init-template` 初始化技术栈模板;`+deploy` 构建、校验并发布产物 | [`lark-apps-init-template.md`](references/lark-apps-init-template.md), [`lark-apps-deploy.md`](references/lark-apps-deploy.md) | | 旧版存量 HTML 应用(无 Git 管理)继续上传已有静态产物 | `+html-publish`(仅兼容旧链路;新建 html / 创意模式 / creative-design 产物不得使用) | [`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) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | From 48af737693d75b0215fe1e92545e961e1d9fe46d Mon Sep 17 00:00:00 2001 From: duanlikang Date: Mon, 31 Aug 2026 15:36:47 +0800 Subject: [PATCH 50/51] docs(apps): withdraw the local-dev routing row from the domain skill --- skills/lark-apps/SKILL.md | 1 - 1 file changed, 1 deletion(-) diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 9f7b9b9e61..58a4b1776b 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -33,7 +33,6 @@ lark-cli auth login --domain apps | 查单个应用详情(类型、名称、发布状态等) | `+get --app-id ` | [`lark-apps-get.md`](references/lark-apps-get.md) | | 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) | | HTML 应用 / 创意模式 — 写 HTML 页面/网站、静态页、PPT/deck、落地页、仪表盘、UI mockup、原型、线框图、视觉探索 | 加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) | [`creative-design/creative-design.md`](creative-design/creative-design.md) | -| 本地开发 Web 应用并托管产物(脚手架新项目 / 项目根有 `spark.json` 的产物托管项目要构建发布) | `+init-template` 初始化技术栈模板;`+deploy` 构建、校验并发布产物 | [`lark-apps-init-template.md`](references/lark-apps-init-template.md), [`lark-apps-deploy.md`](references/lark-apps-deploy.md) | | 旧版存量 HTML 应用(无 Git 管理)继续上传已有静态产物 | `+html-publish`(仅兼容旧链路;新建 html / 创意模式 / creative-design 产物不得使用) | [`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) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | From bacd725a3664537a9ec7e6deaf867a4805fad620 Mon Sep 17 00:00:00 2001 From: duanlikang Date: Mon, 31 Aug 2026 15:47:43 +0800 Subject: [PATCH 51/51] fix(apps): address review comments on the local-dev chain - refuse non-https redirects on the app-dev transfer client (a registry response could otherwise redirect a tarball fetch to cleartext) - tolerate a literal JSON null root in both spark.json writers - quote the scaffold directory in the next-steps command so a name with spaces cannot split the cd argument - surface a short write hidden by a swallowed Close during extraction - assert the typed --registry validation contract, read the uploaded body with io.ReadAll, and cover the release-get no-sync conditions - correct the identity-mismatch quote and the remote-call statement in the references --- shortcuts/apps/apps_deploy.go | 17 ++++++- shortcuts/apps/apps_deploy_test.go | 10 ++--- shortcuts/apps/apps_init_template.go | 2 +- shortcuts/apps/apps_init_template_test.go | 12 ++++- shortcuts/apps/apps_release_get_test.go | 44 +++++++++++++++++++ shortcuts/apps/apps_spark_config.go | 6 +++ shortcuts/apps/apps_spark_config_test.go | 18 ++++++++ shortcuts/apps/apps_template_fetch.go | 4 +- .../lark-apps/references/lark-apps-deploy.md | 2 +- .../references/lark-apps-init-template.md | 2 +- 10 files changed, 104 insertions(+), 13 deletions(-) diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go index 2e600f373a..9b4a98b6c9 100644 --- a/shortcuts/apps/apps_deploy.go +++ b/shortcuts/apps/apps_deploy.go @@ -378,7 +378,22 @@ var appDevRunner envCommandRunner = execEnvCommandRunner{} // appDevNewTransferClient builds the HTTP client for the presigned TOS // upload. Package-level so unit tests can inject an httptest TLS client // (the command only accepts https upload URLs). -var appDevNewTransferClient = newFileTransferClient +var appDevNewTransferClient = newAppDevTransferClient + +// newAppDevTransferClient hardens the shared file-transfer client for the +// app-dev chain: redirects may hop hosts (registry tarballs commonly live on +// a CDN) but must stay on https — following a downgrade to http would leak +// the request over cleartext. +func newAppDevTransferClient() *http.Client { //nolint:forbidigo // presigned TOS upload and npm registry download bypass the Lark gateway; RuntimeContext.DoAPI does not apply. + c := newFileTransferClient() + c.CheckRedirect = func(req *http.Request, _ []*http.Request) error { //nolint:forbidigo // see above. + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing to follow a non-https redirect to %s", req.URL) //nolint:forbidigo // redirect-policy signal consumed by net/http; the caller wraps the resulting error as typed. + } + return nil + } + return c +} // summarizeReleaseErrorLogs flattens a release's error_logs (slice of // {step, error_log} objects) into one line for the failure message. diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go index 5167cca90f..cc8b599f46 100644 --- a/shortcuts/apps/apps_deploy_test.go +++ b/shortcuts/apps/apps_deploy_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -468,10 +469,6 @@ func TestAppDevPublishValidate_BadAppID(t *testing.T) { if !strings.Contains(p.Message, "spark.json app id") { t.Errorf("message should point at the config source, got %q", p.Message) } - // This command has no --app-id flag; the error must not mention one. - if strings.Contains(p.Message, "--app-id") || strings.Contains(p.Hint, "--app-id") { - t.Errorf("error must not reference a nonexistent --app-id flag: %v", p) - } if !strings.Contains(p.Hint, "+list") { t.Errorf("hint = %q", p.Hint) } @@ -698,9 +695,8 @@ func TestAppDevPublishExecute_SyncSuccess(t *testing.T) { var contentType string srv := newTOSTLSServer(t, func(w http.ResponseWriter, r *http.Request) { contentType = r.Header.Get("Content-Type") - buf := make([]byte, r.ContentLength) - _, _ = r.Body.Read(buf) - uploaded = buf + b, _ := io.ReadAll(r.Body) + uploaded = b w.WriteHeader(200) }) f := &fakeEnvRunner{sideEffect: func() { diff --git a/shortcuts/apps/apps_init_template.go b/shortcuts/apps/apps_init_template.go index f688deebed..cf82d3cbb4 100644 --- a/shortcuts/apps/apps_init_template.go +++ b/shortcuts/apps/apps_init_template.go @@ -262,7 +262,7 @@ var AppsInitTemplate = common.Shortcut{ } devPrefix := "" if dir != "." { - devPrefix = "cd " + dir + " && " + devPrefix = fmt.Sprintf("cd %q && ", dir) } nextSteps := []string{ devPrefix + "npm install && npm run dev", diff --git a/shortcuts/apps/apps_init_template_test.go b/shortcuts/apps/apps_init_template_test.go index 45c05b29bd..3ddcf58095 100644 --- a/shortcuts/apps/apps_init_template_test.go +++ b/shortcuts/apps/apps_init_template_test.go @@ -9,6 +9,7 @@ import ( "compress/gzip" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -557,7 +558,16 @@ func TestResolveAppDevRegistries(t *testing.T) { } // http and bare hosts are rejected. for _, bad := range []string{"http://registry.npmjs.org", "registry.npmjs.org", "ftp://x"} { - if _, err := resolveAppDevRegistries(rctxWith(bad)); err == nil || !strings.Contains(err.Error(), "https") { + _, err := resolveAppDevRegistries(rctxWith(bad)) + if err == nil { + t.Errorf("registry %q must be rejected", bad) + continue + } + var verr *errs.ValidationError + if !errors.As(err, &verr) || verr.Param != "--registry" { + t.Errorf("registry %q: want a --registry validation error, got %v", bad, err) + } + if !strings.Contains(err.Error(), "https") { t.Errorf("registry %q must be rejected with an https hint, got %v", bad, err) } } diff --git a/shortcuts/apps/apps_release_get_test.go b/shortcuts/apps/apps_release_get_test.go index 0035d8c91e..ee7db9d619 100644 --- a/shortcuts/apps/apps_release_get_test.go +++ b/shortcuts/apps/apps_release_get_test.go @@ -422,3 +422,47 @@ func TestAppsReleaseGetJSONOnlineURLPassthrough(t *testing.T) { t.Errorf("JSON must passthrough online_url, got: %v", env.Data["online_url"]) } } + +func TestReleaseGetDoesNotSyncBeforeFinish(t *testing.T) { + cases := []struct { + name string + body map[string]interface{} + }{ + {"publishing release", map[string]interface{}{"release_id": "rel_1", "status": "publishing"}}, + {"finished without url", map[string]interface{}{"release_id": "rel_1", "status": "finished"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + seed := `{"app":{"id":"app_x"}}` + if err := os.WriteFile(filepath.Join(dir, "spark.json"), []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + + rctx, _, reg := newStatusRuntimeContext(t, "app_x", "rel_1") + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/releases/rel_1", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{"release": tc.body}, + }, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("unexpected: %v", err) + } + b, _ := os.ReadFile(filepath.Join(dir, "spark.json")) + if string(b) != seed { + t.Errorf("spark.json must stay untouched before a finished release with a url, got %s", b) + } + }) + } +} diff --git a/shortcuts/apps/apps_spark_config.go b/shortcuts/apps/apps_spark_config.go index 260388bfc5..efd169a7a5 100644 --- a/shortcuts/apps/apps_spark_config.go +++ b/shortcuts/apps/apps_spark_config.go @@ -124,6 +124,9 @@ func writeSparkAppSection(dir, appID, appURL string) error { } else if !os.IsNotExist(err) { return appsFileIOError(err, "read %s failed: %v", sparkJSONRelPath, err) } + if doc == nil { // a literal JSON null unmarshals to a nil map + doc = map[string]interface{}{} + } app := map[string]interface{}{"id": appID} if appURL != "" { app["online_url"] = appURL @@ -174,6 +177,9 @@ func writeSparkScaffoldFields(dir, stack, version string) error { } else if !os.IsNotExist(err) { return appsFileIOError(err, "read %s failed: %v", sparkJSONRelPath, err) } + if doc == nil { // a literal JSON null unmarshals to a nil map + doc = map[string]interface{}{} + } if cur, _ := doc["stack"].(string); strings.TrimSpace(cur) == "" { doc["stack"] = stack } diff --git a/shortcuts/apps/apps_spark_config_test.go b/shortcuts/apps/apps_spark_config_test.go index 4f11288f61..343131f978 100644 --- a/shortcuts/apps/apps_spark_config_test.go +++ b/shortcuts/apps/apps_spark_config_test.go @@ -228,3 +228,21 @@ func TestSyncSparkAppURL(t *testing.T) { } }) } + +func TestSparkWritersTolerateNullRoot(t *testing.T) { + for name, write := range map[string]func(dir string) error{ + "app section": func(dir string) error { return writeSparkAppSection(dir, "app_x", "https://apps.example/x") }, + "scaffold fields": func(dir string) error { return writeSparkScaffoldFields(dir, "s-webapp", "1.0.0") }, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "spark.json"), []byte("null"), 0o644); err != nil { + t.Fatal(err) + } + if err := write(dir); err != nil { + t.Fatalf("a literal JSON null root must not fail the write: %v", err) + } + readJSONFile(t, filepath.Join(dir, "spark.json")) + }) + } +} diff --git a/shortcuts/apps/apps_template_fetch.go b/shortcuts/apps/apps_template_fetch.go index 60d321cc26..4a66cd3f50 100644 --- a/shortcuts/apps/apps_template_fetch.go +++ b/shortcuts/apps/apps_template_fetch.go @@ -274,7 +274,9 @@ func renderAppDevTemplate(targetDir, projectName string, tgz []byte) (*renderedT return nil, appsFileIOError(err, "create template file %s failed: %v", rel, err) } _, err = io.Copy(out, io.LimitReader(tr, remaining+1)) - out.Close() + if cerr := out.Close(); err == nil { + err = cerr + } if err != nil { return nil, appsFileIOError(err, "write template file %s failed: %v", rel, err) } diff --git a/skills/lark-apps/references/lark-apps-deploy.md b/skills/lark-apps/references/lark-apps-deploy.md index ac54cbb090..61b3764a3e 100644 --- a/skills/lark-apps/references/lark-apps-deploy.md +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -46,7 +46,7 @@ lark-cli apps +deploy --dry-run - `current directory is not a Miaoda app project`:不在项目根执行;`cd` 到含 `spark.json` 的目录。 - `spark.json is missing the required dev.port field`:声明本地 dev 端口(如 `{"dev":{"port":5173}}`)——托管后平台能力依托本地自描述端点(`GET localhost:/spark.json`),必填。 - `the local self-description endpoint is unavailable`:先启动 dev server(官方模板已内置 /spark.json 端点;custom 项目须自己伺服项目根 spark.json);无头/CI 环境用 `--no-verify`——**不要因为端点验证失败就自动加 `--no-verify` 重试**,先确认是环境问题而非发错目录。 -- `the dev server ... declares app X, but this directory deploys app Y`:**大概率发错目录**——停下核对当前目录与正在运行的 dev server 是否同一项目,把情况告知用户,不要用 `--no-verify` 绕过。 +- `the dev server ... declares app X, but this deploy targets app Y`:**大概率发错目录**——停下核对当前目录与正在运行的 dev server 是否同一项目,把情况告知用户,不要用 `--no-verify` 绕过。 - `warning: no index.html ...`:不拦截但强烈建议修复——平台 SPA fallback 依赖入口 index.html,缺失时线上路由回退会异常。 - `routes.json is missing` / schema 校验失败:声明了 `build.command` 的项目由构建脚本负责生成合法 routes.json;让用户检查构建配置,不要手工伪造(buildless 项目无此问题,CLI 会自动生成)。 - `build command ... failed`:转述 stderr 摘要让用户修构建错误(构建命令来自 spark.json `build.command`);用户已手动构建时可用 `--skip-build`。 diff --git a/skills/lark-apps/references/lark-apps-init-template.md b/skills/lark-apps/references/lark-apps-init-template.md index 890b198b25..aec110e64f 100644 --- a/skills/lark-apps/references/lark-apps-init-template.md +++ b/skills/lark-apps/references/lark-apps-init-template.md @@ -4,7 +4,7 @@ ## 何时用 -用户要在本地开发一个 Web 应用(纯前端或全栈)并计划后续部署到妙搭时,用它初始化技术栈模板。它不创建妙搭应用、不打任何远端 API、不涉及 git/沙箱;只在本地 scaffold 项目。已有项目目录时不要用它(目标目录必须为空或不存在)。 +用户要在本地开发一个 Web 应用(纯前端或全栈)并计划后续部署到妙搭时,用它初始化技术栈模板。它不创建妙搭应用、不打任何 Lark API、不涉及 git/沙箱(唯一的远端交互是对 npm registry 的只读下载);只在本地 scaffold 项目。已有项目目录时不要用它(目标目录必须为空或不存在)。 ## 命令骨架