diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go new file mode 100644 index 0000000000..9b4a98b6c9 --- /dev/null +++ b/shortcuts/apps/apps_deploy.go @@ -0,0 +1,708 @@ +// 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" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// 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 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 +// 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 +} + +// 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. 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 { + // 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 spark.json)" + if cfg.Buildless() { + 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 (spark.json build.output, default dist/output)", cfg.BuildOutput). + WithHint(hint) + } + return nil, -1, err + } + var htmlRels []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 + } + entries = append(entries, appDevPackEntry{ZipPath: "output/" + c.RelPath, AbsPath: c.AbsPath, Size: c.Size}) + } + if cfg.BuildOutputCDN != "" { + cdnFiles, err := walkHTMLPublishCandidates(fio, cfg.BuildOutputCDN) + 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 + } + } + 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") + } + 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 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 +} + +// 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"` + } + 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 b, len(routes), nil +} + +// 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 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 +// 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 + if err := json.Unmarshal(b, &routes); err != nil { + 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("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("routes.json has duplicate path %q (paths must be unique)", path). + WithHint(appDevRoutesHint) + } + seen[path] = true + } + return nil +} + +// 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.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 +} + +// 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 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://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 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 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 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 { + ID string `json:"id"` + } `json:"app"` + } + if json.Unmarshal(body, &doc) != nil { + 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 +} + +// 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 +// 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. 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 == "" || endpointID == targetAppID { + return nil + } + return appsFailedPreconditionError( + "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") +} + +// 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 +// decision. +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: +// - 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 +func resolveAppDevPublishTarget(rctx *common.RuntimeContext) (cfg *appDevProjectConfig, appID string, fromFlag bool, err error) { + flagID := strings.TrimSpace(rctx.Str("app-id")) + cfg, found, err := readAppDevProjectConfig(".") + if err != nil { + return nil, "", false, err + } + if !found { + return nil, "", false, appsFailedPreconditionError( + "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", 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", 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 { + return nil, "", false, err + } + return cfg, flagID, recorded == "", nil + default: + if !strings.HasPrefix(recorded, "app_") { + return nil, "", false, appsFailedPreconditionError( + `%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 + } +} + +// 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 +deploy'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 = 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. +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 +} + +// 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)) + 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 + } +} + +// AppsDeploy builds and publishes a local web app project to its +// 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 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 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 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 localhost:/spark.json availability check, and the app-identity match)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + cfg, targetAppID, _, err := resolveAppDevPublishTarget(rctx) + if err != nil { + return err + } + if !rctx.Bool("no-verify") { + if err := validateSparkDeclaration(cfg); err != nil { + return err + } + if err := verifyLocalEndpointIdentity(cfg, targetAppID); err != nil { + return err + } + } + switch { + case cfg.Buildless(): + if _, err := rctx.FileIO().Stat(cfg.BuildOutput); err != nil { + 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 { + 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") + } + 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 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 (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{} + 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.json on success)") + } else { + dry.Set("app_id_source", sparkJSONRelPath) + } + dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))). + PUT(" (https only)"). + POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))). + Body(map[string]string{}) + } + if cfg.Buildless() { + 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 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 != "" { + 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); verr != nil { + dry.Set("output_validation_error", verr.Error()) + } else { + 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)) + } + 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 + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + 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 := sparkJSONRelPath + 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. + 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 := 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 %s is not https; refusing to upload", appDevUploadURLKey) + } + + built := false + 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, ", ")) + } + 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 spark.json)") + } + built = true + } + + entries, generatedRoutes, err := validateAppDevOutputs(rctx.FileIO(), cfg) + if err != nil { + return err + } + 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 + } + + //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) + } + 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). + 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 + // 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{}{}) + 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") + // 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 != "" { + finalStatus, finalURL, werr := resolveAppDevReleaseOutcome(ctx, rctx, appID, releaseID, status) + if werr != nil { + return werr + } + if finalStatus != "" { + status = finalStatus + } + onlineURL = finalURL + if onlineURL == "" { + 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 %s accepted (status %s); poll with `lark-cli apps +release-get`\n", releaseID, status) + } + } + } + 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 + } else if releaseID != "" { + 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: + // 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) + if onlineURL != "" { + fmt.Fprintf(w, "online_url: %s\n", onlineURL) + } else if pollHint != "" { + fmt.Fprintf(w, "async release; poll with: %s\n", pollHint) + } + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_deploy_test.go b/shortcuts/apps/apps_deploy_test.go new file mode 100644 index 0000000000..cc8b599f46 --- /dev/null +++ b/shortcuts/apps/apps_deploy_test.go @@ -0,0 +1,1104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "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) + } +} + +// --- artifact layout validation --- + +// testAppDevCfg builds a resolved project config for validation tests. +// buildless mirrors a spark.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 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 { + p := filepath.Join(base, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + body := "x" + if strings.HasSuffix(f, "routes.json") { + body = `[{"path":"/","file":"index.html"}]` + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestValidateAppDevOutputs(t *testing.T) { + tests := []struct { + name string + files []string + buildless bool + wantErr string // "" = valid + }{ + {"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) { + out := filepath.Join(t.TempDir(), "dist", "output") + writeDistFiles(t, out, tt.files) + 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) + } + return + } + 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 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)) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, e := range entries { + got[e.ZipPath] = true + } + 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) + } + } + // 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) + } + } +} + +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(out, "routes.json"), []byte(body), 0o644) + } + check := func(body, wantErr string) { + t.Helper() + set(body) + _, _, err := validateAppDevOutputs(permissiveFIO{}, testAppDevCfg(out, "", 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 TestValidateAppDevOutputs_Missing(t *testing.T) { + missing := filepath.Join(t.TempDir(), "dist", "output") + _, _, 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) + } + 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)) + p = requireAppsProblem(t, err, errs.CategoryValidation) + if !strings.Contains(p.Hint, "no build.command") { + t.Errorf("buildless hint = %q", p.Hint) + } +} + +// --- zip packing --- + +func TestBuildAppDevZip(t *testing.T) { + 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)) + if err != nil { + t.Fatal(err) + } + zipball, err := buildAppDevZip(permissiveFIO{}, entries) + 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 (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)) + 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) + } +} + +// --- 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 }) +} + +// chdirSparkProjectRoot creates a temp project root with spark.json and +// 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 { + 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": "artifact_url", "value": uploadURL}, + } + 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 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", + URL: "/open-apis/spark/v1/apps/" + appID + "/releases", + Body: map[string]interface{}{ + "code": float64(0), + "data": respData, + }, + }) +} + +func TestAppDevPublishValidate_NoMeta(t *testing.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, "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) + } +} + +func TestAppDevPublishValidate_NoAppID(t *testing.T) { + 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) + 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, "+deploy --app-id") { + t.Errorf("hint = %q", p.Hint) + } +} + +func TestAppDevPublishValidate_AppIDMismatch(t *testing.T) { + 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, "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_FlagMatchesMeta(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"}) + 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, 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) + } +} + +func TestAppDevPublishValidate_BadAppID(t *testing.T) { + 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.json app id") { + t.Errorf("message should point at the config source, got %q", p.Message) + } + if !strings.Contains(p.Hint, "+list") { + t.Errorf("hint = %q", p.Hint) + } +} + +func TestAppDevPublishValidate_Declaration(t *testing.T) { + // 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 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) + // 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) + } + }) + } +} + +func TestAppDevPublishExecute_MissingIndexHTMLWarns(t *testing.T) { + // A payload without index.html publishes (warning only, per the protocol + // 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"}) + 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) + } +} + +// 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 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} + } + t.Run("mismatch is rejected", func(t *testing.T) { + port := localEndpointServer(t, `{"stack":"custom-webapp","app":{"id":"app_other"}}`, 200) + 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(port), "app_mine"); err != nil { + t.Errorf("matching identity must pass: %v", err) + } + }) + 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), "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(port), "app_mine") + 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(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) + } + }) +} + +func TestAppDevPublishValidate_NoVerifySkipsEndpointGate(t *testing.T) { + // --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 (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, "dev.port") { + 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. + 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_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) + 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) + } +} + +func TestAppDevPublishValidate_BuildlessNoDist(t *testing.T) { + // No build.command declared in spark.json (buildless): the artifact + // directory must already exist. + 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) + 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 := chdirSparkProjectRoot(t, `{ + "stack": "react-standard-webapp", + "dev": { "port": 5173 }, + "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) { + contentType = r.Header.Get("Content-Type") + b, _ := io.ReadAll(r.Body) + uploaded = b + w.WriteHeader(200) + }) + f := &fakeEnvRunner{sideEffect: func() { + writeDistFiles(t, filepath.Join(root, "dist", "output"), []string{"index.html", "routes.json", "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://apps.example/app/app_x", + }) + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--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://apps.example/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") + } + // 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{}) + 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"]) + } +} + +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, `{"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) + 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, AppsDeploy, []string{"+deploy", "--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, sparkJSONRelPath)) + var doc map[string]interface{} + _ = json.Unmarshal(b, &doc) + app, _ := doc["app"].(map[string]interface{}) + if app == nil || app["online_url"] != "https://x/app/app_x" { + t.Errorf("app section after publish = %v", doc["app"]) + } +} + +func TestAppDevPublishExecute_AsyncSuccess(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"}) + 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"}) + // 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) + } + 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 -> the app section carries no url key. + b, _ := os.ReadFile(filepath.Join(root, sparkJSONRelPath)) + if strings.Contains(string(b), "\"online_url\"") { + t.Errorf("spark.json must not gain app.online_url on a still-publishing release: %s", b) + } +} + +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":"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) + 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", + }) + 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_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, `{"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) + stubPreRelease(reg, "app_x", srv.URL, nil) + 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"}, + }, + }) + 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) + } + 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) { + 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) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", srv.URL, nil) + 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) + } + if !strings.Contains(p.Hint, "--skip-build") { + t.Errorf("hint = %q", p.Hint) + } +} + +func TestAppDevPublishExecute_PreReleaseMissingKVs(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"}) + 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, 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) + } +} + +func TestAppDevPublishExecute_NonHTTPSUploadURL(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"}) + factory, stdout, reg := newAppsExecuteFactory(t) + stubPreRelease(reg, "app_x", "http://insecure.example/put", nil) + 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) + } +} + +func TestAppDevPublishExecute_TOS5xx(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"}) + 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, 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") + } +} + +func TestAppDevPublishDryRun(t *testing.T) { + 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 { + 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"]) + } + // 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_*") { + t.Errorf("build_command = %q", buildCmd) + } +} + +func TestAppDevPublishDryRun_Buildless(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"}) + factory, stdout, _ := newAppsExecuteFactory(t) + 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()) + 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) { + // spark.json declares a custom build command and output dir; the app + // 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" } +}`) + 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{"index.html", "routes.json"}) + }} + 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, AppsDeploy, []string{"+deploy", "--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, sparkJSONRelPath)) + 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/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 spark.json: --app-id publishes and the app + // section is written on success (async: no url yet). + 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) + stubPreRelease(reg, "app_new1", srv.URL, nil) + stubReleases(reg, "app_new1", map[string]interface{}{"release_id": "rel_21", "status": "pending"}) + 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, sparkJSONRelPath)) + 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["online_url"]; has { + t.Error("async publish must not write app.url") + } +} + +func TestAppDevPublishValidate_MiaodaMismatch(t *testing.T) { + 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, "spark.json") || !strings.Contains(p.Message, "app_recorded") { + t.Errorf("message = %q", p.Message) + } +} + +func TestAppsDeploy_Declaration(t *testing.T) { + if AppsDeploy.Command != "+deploy" { + t.Errorf("Command = %q", AppsDeploy.Command) + } + if AppsDeploy.Risk != "write" { + t.Errorf("Risk = %q", AppsDeploy.Risk) + } + if !AppsDeploy.HasFormat { + t.Error("HasFormat = false") + } + if len(AppsDeploy.Scopes) != 2 { + 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.go b/shortcuts/apps/apps_deploy_zip.go new file mode 100644 index 0000000000..56d594e4ed --- /dev/null +++ b/shortcuts/apps/apps_deploy_zip.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/zip" + "bytes" + "io" + + "github.com/larksuite/cli/extension/fileio" +) + +// appDevZipball is an in-memory zip payload ready for TOS upload. +type appDevZipball struct { + Body []byte + Size int64 + FileCount int +} + +// 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 buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, e := range entries { + w, err := zw.Create(e.ZipPath) + if err != nil { + 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(e.AbsPath) + if err != nil { + 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", e.ZipPath, err) + } + } + if err := zw.Close(); err != nil { + return nil, appsFileIOError(err, "zip finalize failed: %v", err) + } + size := int64(buf.Len()) + return &appDevZipball{Body: buf.Bytes(), Size: size, FileCount: len(entries)}, nil +} diff --git a/shortcuts/apps/apps_deploy_zip_test.go b/shortcuts/apps/apps_deploy_zip_test.go new file mode 100644 index 0000000000..344369e100 --- /dev/null +++ b/shortcuts/apps/apps_deploy_zip_test.go @@ -0,0 +1,33 @@ +// 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 +} + +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_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_init_template.go b/shortcuts/apps/apps_init_template.go new file mode 100644 index 0000000000..cf82d3cbb4 --- /dev/null +++ b/shortcuts/apps/apps_init_template.go @@ -0,0 +1,288 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// 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" + appDevTemplateHTML = "html-standard-webapp" +) + +// appDevLookPath is swappable in tests to simulate a missing binary +// (+deploy uses it for its npm precondition check). +var appDevLookPath = exec.LookPath + +// appDevTemplateForType maps the +init-template --type value to its +// template short name. Unknown types return "". +func appDevTemplateForType(appType string) string { + switch appType { + case "frontend": + return appDevTemplateFrontend + case "full_stack": + return appDevTemplateFullstack + case "html": + return appDevTemplateHTML + } + 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 +} + +// 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()). +func resolveAppDevDir(dir string) string { + d := strings.TrimSpace(dir) + if d == "" { + 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 { + 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 base +} + +// 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 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 { + if os.IsNotExist(err) { + return nil + } + 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") + } + return nil +} + +// 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 AppsInitTemplate = common.Shortcut{ + Service: appsService, + 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 +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. + Scopes: []string{}, + 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, 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 { + template, _ := resolveAppDevTemplate(rctx) // Validate already rejected invalid input + 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)") + dry.Set("template_package", pkg) + 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) + 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 { + 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", "read-only npm registry download, no Lark API") + return dry + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + template, err := resolveAppDevTemplate(rctx) + if err != nil { + return err + } + dir := resolveAppDevDir(rctx.Str("dir")) + 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")), registries, func(note string) { + fmt.Fprintf(rctx.IO().ErrOut, "registry %s\n", note) + }) + 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) + } + rendered, err := renderAppDevTemplate(dir, appDevProjectName(dir), tgz) + if err != nil { + return err + } + if err := writeSparkScaffoldFields(dir, template, version); err != nil { + return err + } + devPrefix := "" + if dir != "." { + devPrefix = fmt.Sprintf("cd %q && ", dir) + } + 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 spark.json on success; later runs need no flag)", + } + data := map[string]interface{}{ + "dir": dir, + "template": template, + "stack": template, + "version": version, + "files": rendered.Files, + "next_steps": nextSteps, + } + 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) + } + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_init_template_test.go b/shortcuts/apps/apps_init_template_test.go new file mode 100644 index 0000000000..3ddcf58095 --- /dev/null +++ b/shortcuts/apps/apps_init_template_test.go @@ -0,0 +1,835 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "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"}, + {"html", "html", "html-standard-webapp"}, + {"unknown", "vue", ""}, + {"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 TestAppDevTemplatePackageName(t *testing.T) { + if got := appDevTemplatePackageName("react-standard-webapp"); got != "@lark-apaas/coding-template-react-standard-webapp" { + t.Errorf("package name = %q", got) + } +} + +func TestResolveAppDevDir(t *testing.T) { + if got := resolveAppDevDir(""); got != "." { + t.Errorf("default dir = %q, want . (in-place init)", got) + } + 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 { + 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) + } +} + +// --- 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", "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) + }) + mux.HandleFunc("/tarball.tgz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(tgz) + }) + srv = httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL} + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) + 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, 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, 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, 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) { + 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) + } + // 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 TestWriteMiaodaScaffoldFields(t *testing.T) { + dir := t.TempDir() + // Fresh project: stack + version stamped. + if err := writeSparkScaffoldFields(dir, "react-standard-webapp", "1.2.3"); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(dir, sparkJSONRelPath)) + if err != nil { + t.Fatal(err) + } + var doc map[string]interface{} + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + if doc["stack"] != "react-standard-webapp" || doc["version"] != "1.2.3" { + t.Errorf("doc = %v", doc) + } + // 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, sparkJSONRelPath), []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + if err := writeSparkScaffoldFields(dir, "react-standard-webapp", "2.0.0"); err != nil { + t.Fatal(err) + } + b, _ = os.ReadFile(filepath.Join(dir, sparkJSONRelPath)) + 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) + } +} + +// --- 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) + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL} + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) + + _, _, 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) + } +} + +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) + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL} + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) + + _, _, 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) + } +} + +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) + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL} + 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", "") + 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, "", nil, 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, 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, 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 TestAppsInitTemplate_Declaration(t *testing.T) { + if AppsInitTemplate.Command != "+init-template" { + t.Errorf("Command = %q", AppsInitTemplate.Command) + } + if AppsInitTemplate.Service != appsService { + t.Errorf("Service = %q", AppsInitTemplate.Service) + } + if AppsInitTemplate.Risk != "write" { + t.Errorf("Risk = %q, want write", AppsInitTemplate.Risk) + } + if !AppsInitTemplate.HasFormat { + t.Error("HasFormat = false, want true") + } + if AppsInitTemplate.Scopes == nil { + t.Error("Scopes must be non-nil (no Lark API => empty slice)") + } +} + +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: "+init-template"} + 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: "+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/")) + if err != nil || len(regs) != 1 || regs[0] != "https://bnpm.example" { + 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"} { + _, 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) + } + } +} + +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 + }{ + {"missing type and template", "", "", "--type or --template 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 := 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) + } + }) + } +} + +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 +// 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 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, AppsInitTemplate, + []string{"+init-template", "--type", "frontend", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["dir"] != dir || data["template"] != "react-standard-webapp" || data["version"] != "1.2.3" { + t.Errorf("data = %v", data) + } + if data["files"] != float64(5) { + t.Errorf("files = %v", data["files"]) + } + // 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) + } + // spark.json written by lark-cli per the hosting protocol. + 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("spark.json = %v", doc) + } + steps, _ := data["next_steps"].([]interface{}) + if len(steps) != 3 { + t.Errorf("next_steps = %v", data["next_steps"]) + } +} + +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, AppsInitTemplate, + []string{"+init-template", "--type", "full_stack", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["template"] != "react-express-standard-fullstack" { + t.Errorf("template = %v", data["template"]) + } +} + +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, AppsInitTemplate, + []string{"+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() + mux.HandleFunc("/"+pkg, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }) + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + origRegs, origClient := appDevRegistries, appDevNewTransferClient + appDevRegistries = []string{srv.URL} + appDevNewTransferClient = func() *http.Client { return srv.Client() } + t.Cleanup(func() { appDevRegistries, appDevNewTransferClient = origRegs, origClient }) + + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relAppDevDir(t) + 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") + } + if _, statErr := os.Stat(dir); !os.IsNotExist(statErr) { + t.Error("target dir must not be created when the fetch fails") + } +} + +func TestAppDevInitTemplateExecute_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) + } + factory, stdout, _ := newAppsExecuteFactory(t) + 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) + } +} + +func TestAppDevInitTemplateDryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + 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()) + if err != nil { + t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String()) + } + if data["template_package"] != "@lark-apaas/coding-template-react-standard-webapp" { + t.Errorf("template_package = %v", data["template_package"]) + } + 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"] != "." { + 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) + } +} + +func TestAppDevInitTemplateDryRun_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, 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()) + 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) + } +} + +// --- 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_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") + } +} 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..ee7db9d619 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["online_url"] != "https://x/app/app_x" { + t.Errorf("app.online_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{ @@ -365,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 new file mode 100644 index 0000000000..efd169a7a5 --- /dev/null +++ b/shortcuts/apps/apps_spark_config.go @@ -0,0 +1,195 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +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 +// protocol declaration: how to dev/build, plus the app state +// section written back by the deploy chain. +const sparkJSONRelPath = "spark.json" + +// appDevDefaultBuildOutput is the protocol default for build.output (the +// 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" + +// appDevProjectConfig is the resolved view of the project declaration that +// +deploy consumes. Fields are filled with protocol defaults when +// the declaration omits them. +type appDevProjectConfig struct { + Stack string + Version string + // BuildCommand is nil for buildless projects (no build.command declared): + // the output directory is packed as-is. + BuildCommand []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 + // 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 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 { + Stack string `json:"stack"` + Version string `json:"version"` + Dev struct { + Port int `json:"port"` + } `json:"dev"` + Build struct { + Command []string `json:"command"` + Output string `json:"output"` + OutputCDN string `json:"output_cdn"` + } `json:"build"` + App struct { + ID string `json:"id"` + URL string `json:"online_url"` + } `json:"app"` +} + +// 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, 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 + } + return nil, false, appsFileIOError(rerr, "read %s failed: %v", sparkJSONRelPath, rerr) + } + var doc sparkJSONDoc + if jerr := json.Unmarshal(b, &doc); jerr != nil { + return nil, true, appsFileIOError(jerr, "parse %s failed: %v", sparkJSONRelPath, jerr) + } + cfg = &appDevProjectConfig{ + 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), + AppID: strings.TrimSpace(doc.App.ID), + AppURL: strings.TrimSpace(doc.App.URL), + } + applyAppDevConfigDefaults(cfg) + return cfg, true, nil +} + +// applyAppDevConfigDefaults fills the protocol defaults: build.output → +// dist/output. build.command deliberately has no default — missing means +// buildless, and build.output_cdn stays empty when undeclared. +func applyAppDevConfigDefaults(cfg *appDevProjectConfig) { + 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 } + +// writeSparkAppSection replaces the app state section of /spark.json +// 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 { + 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", sparkJSONRelPath, jerr) + } + } 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 + } + doc["app"] = app + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + 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", sparkJSONRelPath, err) + } + 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.online_url into %s: %v\n", sparkJSONRelPath, werr) + return + } + fmt.Fprintf(rctx.IO().ErrOut, "app.online_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 +// template seed did not declare one, and every other field the seed shipped +// (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{}{} + 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", sparkJSONRelPath, jerr) + } + } 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 + } + doc["version"] = version + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + 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", sparkJSONRelPath, err) + } + return nil +} diff --git a/shortcuts/apps/apps_spark_config_test.go b/shortcuts/apps/apps_spark_config_test.go new file mode 100644 index 0000000000..343131f978 --- /dev/null +++ b/shortcuts/apps/apps_spark_config_test.go @@ -0,0 +1,248 @@ +// 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") + } + }) +} + +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 new file mode 100644 index 0000000000..4a66cd3f50 --- /dev/null +++ b/shortcuts/apps/apps_template_fetch.go @@ -0,0 +1,324 @@ +// 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" + "sort" + "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/" + +// 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 +// 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"` +} + +// fetchAppDevTemplate resolves and downloads the template package, trying +// 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 registries { + if i > 0 && onFallback != nil { + onFallback(strings.TrimRight(registries[i-1], "/") + " failed, falling back to " + strings.TrimRight(base, "/")) + } + v, tarballURL, err := fetchAppDevTemplateMeta(ctx, base, pkg, requested) + 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(registries, ", ") + "); check network access and whether the template package is published" + } + return "", nil, lastErr +} + +// 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") + 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) + } + resolved := strings.TrimSpace(requested) + if resolved == "" { + resolved = "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, 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 + // tampering / registry compromise) — refuse rather than follow it. + 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 resolved, 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 { + Files int +} + +// 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() + // 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) + 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) + } + 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, + // 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 !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 - 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) + } + _, err = io.Copy(out, io.LimitReader(tr, remaining+1)) + if cerr := out.Close(); err == nil { + err = cerr + } + if err != nil { + return nil, appsFileIOError(err, "write template file %s failed: %v", rel, 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") + } + } + + 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) + } + } + } + + return &renderedTemplate{Files: files}, 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 +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index fb08c062c3..ad0431be75 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -34,6 +34,8 @@ func Shortcuts() []common.Shortcut { AppsMemberSettingsSet, AppsHTMLPublish, AppsInit, + AppsInitTemplate, + AppsDeploy, AppsReleaseCreate, AppsReleaseList, AppsReleaseGet, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index a8dec3717a..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 +// 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/ @@ -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)) } } diff --git a/skills/lark-apps/references/lark-apps-deploy.md b/skills/lark-apps/references/lark-apps-deploy.md new file mode 100644 index 0000000000..61b3764a3e --- /dev/null +++ b/skills/lark-apps/references/lark-apps-deploy.md @@ -0,0 +1,53 @@ +# apps +deploy + +把本地 Web 应用项目一键构建并发布到它的妙搭应用(产物托管形态)。运行时命令事实以 `lark-cli apps +deploy --help` 为准。 + +## 何时用 + +用 `+init-template` 初始化(或按产物协议改造)的本地项目要部署/更新到妙搭时使用。它不适用于 html 应用(走 `+html-publish`)或源码托管应用(走 `+release-create`)。 + +## 命令骨架 + +- **必须在项目根目录执行**(项目根须有 `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 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。 + +## 示例 + +```bash +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 +``` + +## 输出契约 + +- 发布单受理后**命令立即返回,不原地等待**(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 段(`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`,可稍后重试。 + +## 前置引导 + +- 未记录 app id 时:先 `lark-cli apps +create --name ` 创建应用,然后 `lark-cli apps +deploy --app-id <返回的 app_id>` 发布(成功后自动写入 spark.json,无需手工编辑文件);应用名可从项目主题生成,不要让用户手动提供 app_id。 +- **记录的 app id 不是本会话写入的**(来自历史文件或他人仓库)时,发布前先把目标 app id 告知用户并确认——发布会覆盖该应用的线上内容。 + +## 安全规则 + +- 构建环境变量只注入 `pre_release` 下发的 `MIAODA_*` 白名单键;命令会在 stderr 回显实际注入的键名。 + +## 常见失败 + +- `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 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`。 +- `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 new file mode 100644 index 0000000000..aec110e64f --- /dev/null +++ b/skills/lark-apps/references/lark-apps-init-template.md @@ -0,0 +1,39 @@ +# apps +init-template + +在本地初始化一个产物托管形态的 Web 应用项目(代码留在本地,构建产物后续发布到妙搭)。运行时命令事实以 `lark-cli apps +init-template --help` 为准。 + +## 何时用 + +用户要在本地开发一个 Web 应用(纯前端或全栈)并计划后续部署到妙搭时,用它初始化技术栈模板。它不创建妙搭应用、不打任何 Lark API、不涉及 git/沙箱(唯一的远端交互是对 npm registry 的只读下载);只在本地 scaffold 项目。已有项目目录时不要用它(目标目录必须为空或不存在)。 + +## 命令骨架 + +- 可选:`--template-version`,钉某个模板包版本或 dist-tag(如 `alpha`);缺省 latest。 +- `--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` 则创建子目录。 +- 可选:`--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 +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 +``` + +## 输出契约 + +返回 `data.dir`(项目目录)、`data.template`、`data.stack` 和 `data.next_steps`(后续步骤清单)。按 next_steps 引导用户: + +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 写入 spark.json,后续免传;见 [lark-apps-deploy.md](lark-apps-deploy.md))。 + +## 常见失败 + +- `target directory ... already exists and is not empty`:换 `--dir` 或让用户清空目录;不要擅自删除已有内容。 +- `npm registry returned 404`:主源与官方源都取不到时报出,模板包可能未发布,转述 hint(联系产物侧或检查网络/registry 可达性)。 +- registry 5xx / 网络失败:错误带 retryable,可稍后重试。