diff --git a/go.mod b/go.mod index 8839b7fc71..ec841324ce 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require github.com/tdewolff/parse/v2 v2.8.16 + require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect diff --git a/go.sum b/go.sum index 574157832b..b34922e250 100644 --- a/go.sum +++ b/go.sum @@ -121,6 +121,10 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8feI= +github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= +github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= +github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= diff --git a/shortcuts/apps/apps_deploy.go b/shortcuts/apps/apps_deploy.go new file mode 100644 index 0000000000..6afa1a0714 --- /dev/null +++ b/shortcuts/apps/apps_deploy.go @@ -0,0 +1,736 @@ +// 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 — and must not change the method. +// +// The method rule protects the artifact upload. On a 301, 302 or 303 net/http +// turns a PUT into a bodyless GET; if that GET answers 2xx the upload looks +// like it succeeded and the release is created against an artifact that was +// never stored. Nothing downstream can tell, because the only evidence of the +// upload is that status code. A redirect that keeps the method (307, 308) +// replays the body and stays allowed. +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, via []*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. + } + if len(via) > 0 && via[0].Method != req.Method { + return fmt.Errorf("refusing to follow a redirect that turns %s into %s (the request body would be dropped)", via[0].Method, req.Method) //nolint:forbidigo // see above. + } + 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: "Publish to a Miaoda app: a local project from its root (spark.json), or a bare HTML file or directory via --file-path / --dir", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +deploy (project mode: run from the project root holding spark.json)", + "Example: lark-cli apps +deploy --file-path ./report.html (publish the page and the css/js/images it references)", + "Example: lark-cli apps +deploy --dir ./site --entry-file home.html (publish a directory; the entry is served as index.html)", + "--file-path follows the page's references; --dir packs the directory as-is and follows none. Either way the entry's own directory is the site root, so keep every file the page needs inside it", + "Add --dry-run to any of these to see the exact file list and the request bodies without publishing", + "Paths are relative to the current directory: cd to the payload first, absolute paths are rejected", + "Re-publishing: pass the --app-id returned last time; without it the target is looked up, and a new app is created when nothing matches", + "--skip-build and --no-verify apply to project mode only", + }, + 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 — in project mode a successful publish saves it back into spark.json and a value conflicting with the recorded one is rejected, while --file-path / --dir never touch spark.json"}, + {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)"}, + {Name: "file-path", Desc: "publish a single HTML file plus the local files it references, transitively (path relative to the current directory); references resolve inside the file's own directory, which becomes the site root, and one climbing above it stops the publish; mutually exclusive with --dir"}, + {Name: "dir", Desc: "publish a whole directory as-is (path relative to the current directory): every file in it ships and no references are followed, so a page pointing outside the directory is reported but still published broken; mutually exclusive with --file-path"}, + {Name: "entry-file", Desc: "entry file name directly under --dir; defaults to index.html and is renamed to index.html inside the published payload"}, + {Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / private keys / etc. in the publish payload)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if isHTMLDeployMode(rctx.Str("file-path"), rctx.Str("dir"), rctx.Str("entry-file")) { + return validateHTMLDeploy(rctx) + } + 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 { + if isHTMLDeployMode(rctx.Str("file-path"), rctx.Str("dir"), rctx.Str("entry-file")) { + return dryRunHTMLDeploy(rctx) + } + 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 { + if isHTMLDeployMode(rctx.Str("file-path"), rctx.Str("dir"), rctx.Str("entry-file")) { + return executeHTMLDeploy(ctx, rctx) + } + cfg, appID, fromFlag, err := resolveAppDevPublishTarget(rctx) + if err != nil { + return err + } + // The server-side owner check is the only authorization line — echo + // the target loudly so a wrong app_id is visible before anything + // ships, naming where the id came from. + source := 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_html.go b/shortcuts/apps/apps_deploy_html.go new file mode 100644 index 0000000000..69d5719fd1 --- /dev/null +++ b/shortcuts/apps/apps_deploy_html.go @@ -0,0 +1,654 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/apps/deploy" + "github.com/larksuite/cli/shortcuts/common" +) + +// isHTMLDeployMode reports whether +deploy should take the bare-HTML path. +// With neither flag set the existing spark.json project mode runs unchanged. +func isHTMLDeployMode(filePath, dir, entryFile string) bool { + // --entry-file counts as a mode selector even though it cannot stand on its + // own: without it here, a lone --entry-file falls through to the project + // mode and the user gets "not a Miaoda app project", which points at + // scaffolding a project instead of at the actual mistake. + return strings.TrimSpace(filePath) != "" || + strings.TrimSpace(dir) != "" || + strings.TrimSpace(entryFile) != "" +} + +// validateHTMLDeployFlags checks the flag combination for the bare-HTML path. +func validateHTMLDeployFlags(filePath, dir, entryFile string, skipBuild, noVerify bool) error { + filePath, dir, entryFile = strings.TrimSpace(filePath), strings.TrimSpace(dir), strings.TrimSpace(entryFile) + if filePath != "" && dir != "" { + return appsValidationParamError("--dir", + "--file-path and --dir are mutually exclusive: use --file-path for a single HTML file, --dir for a directory") + } + if entryFile != "" && dir == "" { + return appsValidationParamError("--entry-file", "--entry-file only applies together with --dir") + } + if skipBuild { + return appsValidationParamError("--skip-build", "--skip-build only applies to the spark.json project mode") + } + if noVerify { + return appsValidationParamError("--no-verify", "--no-verify only applies to the spark.json project mode") + } + // An absolute path is rejected whatever it points at, so saying so first + // spares a round trip: told only about the extension, a caller renames the + // file and comes back to learn the path was never usable. + for _, p := range []struct{ flag, value string }{{"--file-path", filePath}, {"--dir", dir}} { + if p.value != "" && filepath.IsAbs(p.value) { + return appsValidationParamError(p.flag, + "%s %q must be relative to the current directory; cd to the directory that holds the payload first", p.flag, p.value) + } + } + if filePath != "" && !strings.EqualFold(filepath.Ext(filePath), ".html") { + return appsValidationParamError("--file-path", "--file-path %q must point at an .html file", filePath) + } + if entryFile != "" { + return deploy.ValidateEntryFileName(entryFile) + } + return nil +} + +// validateHTMLDeploy is the bare-HTML branch of AppsDeploy.Validate. It checks +// the flag combination, collects the payload off disk and runs the credential +// scan plus the pre-pack size caps. The guard deliberately runs here rather +// than in DryRun so that --dry-run also exits non-zero on a hit. +func validateHTMLDeploy(rctx *common.RuntimeContext) error { + filePath := strings.TrimSpace(rctx.Str("file-path")) + dir := strings.TrimSpace(rctx.Str("dir")) + entryFile := strings.TrimSpace(rctx.Str("entry-file")) + if err := validateHTMLDeployFlags(filePath, dir, entryFile, rctx.Bool("skip-build"), rctx.Bool("no-verify")); err != nil { + return err + } + + var ( + candidates []deploy.Candidate + entryRel string + ) + if filePath != "" { + cands, _, _, err := deploy.CollectFile(rctx.FileIO(), filePath) + if err != nil { + return err + } + candidates, entryRel = cands, cands[0].RelPath + } else { + cands, rootNames, _, err := deploy.CollectDir(rctx.FileIO(), dir) + if err != nil { + return err + } + rel, err := deploy.ResolveEntry(entryFile, rootNames) + if err != nil { + return err + } + candidates, entryRel = cands, rel + } + + if _, err := deploy.Guard(candidates, rctx.Bool("allow-sensitive"), deploy.DefaultLimits()); err != nil { + return err + } + // Building the manifest here is what makes --dry-run exit non-zero on an + // entry collision. Left to Execute, the two input forms would disagree: + // --dir catches it in ResolveEntry during Validate, so a caller who + // previews with --dry-run and only reads the exit code would get a green + // light from --file-path and a failure from the real publish. + if _, _, err := deploy.BuildManifest(candidates, entryRel); err != nil { + return err + } + return nil +} + +// hasHTMLAppCreatedPath is the idempotency lookup. Only exists and app_id are +// consumed; app_url / app_type / ccm_token in the response are ignored. +const hasHTMLAppCreatedPath = apiBasePath + "/apps/has_html_app_created" + +// htmlAppIDSource records how the publish target was resolved, for dry-run and +// stderr echo. +type htmlAppIDSource string + +const ( + htmlAppIDSourceFlag htmlAppIDSource = "--app-id" + htmlAppIDSourceLookup htmlAppIDSource = "has_html_app_created" + htmlAppIDSourceCreate htmlAppIDSource = "+create" +) + +// htmlAppIDFromFlag is the first level: an explicit --app-id wins and skips the +// lookup entirely. +func htmlAppIDFromFlag(flagID string) (htmlAppIDSource, string) { + if id := strings.TrimSpace(flagID); id != "" { + return htmlAppIDSourceFlag, id + } + return htmlAppIDSourceLookup, "" +} + +// parseHasHTMLAppCreated reads exists and app_id out of the lookup response. +func parseHasHTMLAppCreated(data map[string]interface{}) (bool, string) { + exists, _ := data["exists"].(bool) + if !exists { + return false, "" + } + return true, common.GetString(data, "app_id") +} + +// htmlDeployPlan is everything resolved before any write happens; Execute +// consumes it so the payload is not walked twice. +type htmlDeployPlan struct { + AbsEntry string + EntryRel string + AppID string + AppIDSource htmlAppIDSource + Entries []deploy.PackEntry + FileCount int + TotalBytes int64 + ZipPaths []string + RouteCount int + ContentHash string + Waived []string + // SkippedDeps names references found inside the payload that were not + // published. Only --file-path can produce them; --dir publishes the whole + // directory, so nothing a page references inside it can be missing. + SkippedDeps []deploy.Skip +} + +// readHTMLHashFiles reads the payload bytes once for the content fingerprint +// and totals the raw size. The fingerprint path convention is the published +// one (the entry recorded as index.html), which is exactly the zip path minus +// its output/ prefix. Only the collected payload takes part — the +// CLI-generated routes.json is appended afterwards and never fingerprinted. +func readHTMLHashFiles(fio fileio.FileIO, entries []deploy.PackEntry) ([]deploy.HashFile, int64, error) { + files := make([]deploy.HashFile, 0, len(entries)) + var total int64 + for _, e := range entries { + f, err := fio.Open(e.AbsPath) + if err != nil { + return nil, 0, appsInputPathEntryError(e.AbsPath, err) + } + raw, err := io.ReadAll(f) + f.Close() + if err != nil { + return nil, 0, appsFileIOError(err, "read %s failed: %v", e.AbsPath, err) + } + total += int64(len(raw)) + files = append(files, deploy.HashFile{Path: strings.TrimPrefix(e.ZipPath, "output/"), Raw: raw}) + } + return files, total, nil +} + +// htmlDeployRoutesZipPath is where the hosting protocol expects the route +// enumeration inside the zip. +const htmlDeployRoutesZipPath = "output/routes.json" + +// adoptOrGenerateRoutes keeps a routes.json that the payload already carries +// (validating it the same way the project mode does) and only generates one +// when the payload has none. It returns the number of routes the published +// payload ends up declaring. +func adoptOrGenerateRoutes(fio fileio.FileIO, entries *[]deploy.PackEntry, htmlRels []string) (int, error) { + for _, e := range *entries { + if e.ZipPath != htmlDeployRoutesZipPath { + continue + } + f, err := fio.Open(e.AbsPath) + if err != nil { + return 0, appsInputPathEntryError(e.AbsPath, err) + } + raw, err := io.ReadAll(f) + f.Close() + if err != nil { + return 0, appsFileIOError(err, "read %s failed: %v", e.AbsPath, err) + } + if err := validateAppDevRoutesJSON(raw); err != nil { + return 0, err + } + var provided []appDevRoute + if err := json.Unmarshal(raw, &provided); err != nil { + return 0, appsFailedPreconditionError("routes.json is not a valid route enumeration array: %v", err) + } + return len(provided), nil + } + routes, count, err := generateAppDevRoutes(htmlRels) + if err != nil { + return 0, err + } + *entries = append(*entries, deploy.PackEntry{ + ZipPath: htmlDeployRoutesZipPath, Content: routes, Size: int64(len(routes)), + }) + return count, nil +} + +// resolveHTMLDeployPlan walks the payload and resolves everything that can be +// known without a write: the entry, the zip manifest (routes.json included), +// the content fingerprint and the waived credential files. Validate already ran +// the collection and the guard once; re-resolving here mirrors what the project +// mode does with resolveAppDevPublishTarget and keeps DryRun and Execute +// reading the same plan. +func resolveHTMLDeployPlan(rctx *common.RuntimeContext) (htmlDeployPlan, error) { + filePath := strings.TrimSpace(rctx.Str("file-path")) + dir := strings.TrimSpace(rctx.Str("dir")) + entryFile := strings.TrimSpace(rctx.Str("entry-file")) + if err := validateHTMLDeployFlags(filePath, dir, entryFile, rctx.Bool("skip-build"), rctx.Bool("no-verify")); err != nil { + return htmlDeployPlan{}, err + } + + var ( + candidates []deploy.Candidate + entryRel string + absEntry string + skipped []deploy.Skip + ) + if filePath != "" { + cands, abs, missing, err := deploy.CollectFile(rctx.FileIO(), filePath) + if err != nil { + return htmlDeployPlan{}, err + } + // The scan puts the entry first, so its name is still the entry name. + candidates, absEntry, entryRel, skipped = cands, abs, cands[0].RelPath, missing + } else { + cands, rootNames, absDir, err := deploy.CollectDir(rctx.FileIO(), dir) + if err != nil { + return htmlDeployPlan{}, err + } + rel, err := deploy.ResolveEntry(entryFile, rootNames) + if err != nil { + return htmlDeployPlan{}, err + } + candidates, absEntry, entryRel = cands, filepath.Join(absDir, rel), rel + // --dir follows no references, so nothing else would notice that a page + // points at a stylesheet one directory up. Reporting it does not change + // what gets published; it stops the payload from going out looking fine + // and rendering broken. + skipped = deploy.DiagnoseDir(rctx.FileIO(), dir, candidates) + } + + waived, err := deploy.Guard(candidates, rctx.Bool("allow-sensitive"), deploy.DefaultLimits()) + if err != nil { + return htmlDeployPlan{}, err + } + entries, htmlRels, err := deploy.BuildManifest(candidates, entryRel) + if err != nil { + return htmlDeployPlan{}, err + } + hashFiles, totalBytes, err := readHTMLHashFiles(rctx.FileIO(), entries) + if err != nil { + return htmlDeployPlan{}, err + } + contentHash, err := deploy.ContentHash(hashFiles) + if err != nil { + return htmlDeployPlan{}, err + } + // A payload-provided routes.json always wins, matching the project mode + // (validateAppDevOutputs). Appending a generated one unconditionally would + // put two output/routes.json entries in the same zip, and which of them the + // server keeps after unpacking is undefined. + routeCount, err := adoptOrGenerateRoutes(rctx.FileIO(), &entries, htmlRels) + if err != nil { + return htmlDeployPlan{}, err + } + + zipPaths := make([]string, 0, len(entries)) + for _, e := range entries { + zipPaths = append(zipPaths, e.ZipPath) + } + return htmlDeployPlan{ + AbsEntry: absEntry, + EntryRel: entryRel, + Entries: entries, + FileCount: len(entries), + TotalBytes: totalBytes, + ZipPaths: zipPaths, + RouteCount: routeCount, + ContentHash: contentHash, + Waived: waived, + SkippedDeps: skipped, + }, nil +} + +// fillHTMLDeployDryRun prints the resolved publish plan. The idempotency key +// and file_path are this machine's absolute path and do get uploaded, so they +// are echoed verbatim — otherwise the caller cannot see that their user name +// and directory layout leave the machine. +func fillHTMLDeployDryRun(dry *common.DryRunAPI, p htmlDeployPlan) { + dry.Desc("Collect payload -> GET pre_release -> PUT zip to TOS -> POST releases with extra.hash_tag") + dry.Set("app_id_source", string(p.AppIDSource)) + if p.AppID != "" { + dry.Set("app_id", p.AppID) + } + dry.Set("entry_file", p.EntryRel) + // The absolute path only leaves this machine when the target has to be + // looked up or created. Echoing it when --app-id already pinned the target + // would blunt the signal: this field means "this value is being uploaded". + if p.AppIDSource != htmlAppIDSourceFlag { + dry.Set("idempotent_key", p.AbsEntry) + dry.Set("file_path", p.AbsEntry) + } + dry.Set("file_count", p.FileCount) + dry.Set("total_size_bytes", p.TotalBytes) + dry.Set("zip_paths", p.ZipPaths) + dry.Set("routes_json", p.RouteCount) + dry.Set("content_hash", p.ContentHash) + if len(p.Waived) > 0 { + dry.Set("sensitive_waived", p.Waived) + } + if lines := skippedLines(p.SkippedDeps); len(lines) > 0 { + dry.Set("dependencies_skipped", lines) + } +} + +// htmlDeployResult builds the JSON envelope of a finished publish. +// +// Skipped references belong here and not only on stderr: a caller that reads +// stdout would otherwise see an unqualified success for a page that is missing +// files, which is the failure this whole path exists to prevent. +func htmlDeployResult(plan htmlDeployPlan, fileCount int, zipSize int64, releaseID, status string) map[string]interface{} { + data := map[string]interface{}{ + "app_id": plan.AppID, + "release_id": releaseID, + "status": status, + "built": false, + "file_count": fileCount, + "zip_size_bytes": zipSize, + } + if lines := skippedLines(plan.SkippedDeps); len(lines) > 0 { + data["dependencies_skipped"] = lines + } + return data +} + +// skippedLines renders the skip list for the JSON envelope. +func skippedLines(skipped []deploy.Skip) []string { + if len(skipped) == 0 { + return nil + } + out := make([]string, 0, len(skipped)) + for _, sk := range skipped { + out = append(out, sk.String()) + } + return out +} + +// warnSkippedDeps reports references the scan found but could not publish. +// The page still ships, so the warning has to name the consequence rather than +// just the count: a caller who reads "3 files were not published" and moves on +// gets a live URL with broken styling and no idea why. +func warnSkippedDeps(w io.Writer, skipped []deploy.Skip) { + if len(skipped) == 0 { + return + } + fmt.Fprintf(w, "warning: %d reference(s) in the payload could not be published; the published pages will be missing them (broken styles, scripts or images):\n", + len(skipped)) + // One hint per distinct piece of advice, in the order it first came up. + // Grouping by the advice itself rather than by a category is what keeps a + // malformed reference from inheriting the advice written for a missing one. + seen := map[string]bool{} + var order []string + for _, s := range skipped { + fmt.Fprintf(w, " %s\n", s.String()) + if s.Advice != "" && !seen[s.Advice] { + seen[s.Advice] = true + order = append(order, s.Advice) + } + } + for _, advice := range order { + fmt.Fprintf(w, " hint: %s\n", advice) + } +} + +// toAppDevEntries converts the subpackage manifest into the zip builder's type. +func toAppDevEntries(in []deploy.PackEntry) []appDevPackEntry { + out := make([]appDevPackEntry, 0, len(in)) + for _, e := range in { + out = append(out, appDevPackEntry{ + ZipPath: e.ZipPath, AbsPath: e.AbsPath, Content: e.Content, Size: e.Size, + }) + } + return out +} + +// htmlReleaseBody carries the content fingerprint so the server can recognize a +// re-publish of unchanged content. Only the bare-HTML path sends it; the +// project mode keeps its empty body. +func htmlReleaseBody(contentHash string) map[string]interface{} { + body := map[string]interface{}{} + if contentHash == "" { + return body + } + // extra is a JSON *string* on the wire, not a nested object. Sending an + // object still returns 200 — the server just cannot read it — so the + // fingerprint would be silently dropped rather than rejected. + encoded, err := json.Marshal(map[string]string{"hash_tag": contentHash}) + if err != nil { + // map[string]string of one known-good value cannot fail to marshal; + // dropping extra is safer than shipping a malformed body. + return body + } + body["extra"] = string(encoded) + return body +} + +// dryRunHTMLDeploy is the bare-HTML branch of AppsDeploy.DryRun. It resolves +// the plan off disk but issues no request, so the app id stays at whatever the +// flag says — the idempotency lookup itself is a write-shaped POST. +// htmlCreateBody builds the app-creation request for the bare-HTML path. Kept +// separate so the dry-run preview shows exactly the body the live call sends. +func htmlCreateBody(absEntry string) map[string]interface{} { + body := map[string]interface{}{ + "name": deploy.DeriveAppName(absEntry), + "app_type": "html", + // Same key the lookup queries by: creation is what registers it, so a + // later publish of the same entry finds this app instead of making a + // second one. apps has no +delete, so a missed match is not recoverable. + "idempotent_key": absEntry, + } + // Carry the same attribution +create sends. This path exists precisely for + // agent-driven publishing, so dropping it would lose attribution on the + // apps that need it most. + if agent := envvars.AgentName(); agent != "" { + body["source_agent"] = agent + } + return body +} + +func dryRunHTMLDeploy(rctx *common.RuntimeContext) *common.DryRunAPI { + dry := common.NewDryRunAPI() + plan, err := resolveHTMLDeployPlan(rctx) + if err != nil { + dry.Desc("Collect payload -> GET pre_release -> PUT zip to TOS -> POST releases with extra.hash_tag") + dry.Set("plan_error", err.Error()) + return dry + } + plan.AppIDSource, plan.AppID = htmlAppIDFromFlag(rctx.Str("app-id")) + fillHTMLDeployDryRun(dry, plan) + // Mirror the live path's warning: a preview that silently ships credential + // files is worse than one that says so. + if len(plan.Waived) > 0 { + fmt.Fprintf(rctx.IO().ErrOut, + "warning: --allow-sensitive lets %d credential file(s) into the payload: %s\n", + len(plan.Waived), strings.Join(plan.Waived, ", ")) + } + warnSkippedDeps(rctx.IO().ErrOut, plan.SkippedDeps) + + segment := "" + if plan.AppID != "" { + segment = validate.EncodePathSegment(plan.AppID) + } else { + // Without --app-id the target is only known at run time, so spell out + // both branches. The +create branch matters: apps has no +delete, so an + // app created here cannot be removed afterwards. + dry.POST(hasHTMLAppCreatedPath). + Body(map[string]interface{}{"idempotent_key": plan.AbsEntry}) + dry.POST(apiBasePath + "/apps"). + Desc("only when the lookup reports exists=false; creates an app that cannot be deleted afterwards"). + Body(htmlCreateBody(plan.AbsEntry)) + dry.Set("app_id_source", string(htmlAppIDSourceLookup)+", falling back to "+string(htmlAppIDSourceCreate)) + dry.Set("app_name_if_created", deploy.DeriveAppName(plan.AbsEntry)) + } + dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, segment)). + PUT(" (https only)"). + POST(fmt.Sprintf(releaseCreatePath, segment)). + Body(htmlReleaseBody(plan.ContentHash)) + return dry +} + +// resolveHTMLDeployAppID applies the three-level priority: an explicit +// --app-id wins and skips the lookup entirely, otherwise the idempotency +// lookup decides, and a miss creates the app. +func resolveHTMLDeployAppID(rctx *common.RuntimeContext, plan *htmlDeployPlan) error { + source, appID := htmlAppIDFromFlag(rctx.Str("app-id")) + if appID == "" { + data, err := rctx.CallAPITyped("POST", hasHTMLAppCreatedPath, nil, + map[string]interface{}{"idempotent_key": plan.AbsEntry}) + if err != nil { + return withAppsHint(err, appIDListHint) + } + if exists, id := parseHasHTMLAppCreated(data); exists { + appID = id + } + } + if appID == "" { + source = htmlAppIDSourceCreate + data, err := rctx.CallAPITyped("POST", apiBasePath+"/apps", nil, htmlCreateBody(plan.AbsEntry)) + if err != nil { + return withAppsHint(err, createHint) + } + appID = common.GetString(data, "app", "app_id") + if appID == "" { + appID = common.GetString(data, "app_id") + } + if appID == "" { + return appsSubprocessEnvelopeError("app creation response carries no app_id") + } + } + plan.AppID, plan.AppIDSource = appID, source + return nil +} + +// executeHTMLDeploy is the bare-HTML branch of AppsDeploy.Execute. It shares +// the second half of the chain with the project mode (pre_release -> zip -> +// presigned PUT -> releases) but never builds, never touches spark.json and +// tags the release with the payload fingerprint. +func executeHTMLDeploy(ctx context.Context, rctx *common.RuntimeContext) error { + plan, err := resolveHTMLDeployPlan(rctx) + if err != nil { + return err + } + if len(plan.Waived) > 0 { + fmt.Fprintf(rctx.IO().ErrOut, "warning: --allow-sensitive waived the credential scan; publishing %d credential file(s): %s\n", + len(plan.Waived), strings.Join(plan.Waived, ", ")) + } + warnSkippedDeps(rctx.IO().ErrOut, plan.SkippedDeps) + if err := resolveHTMLDeployAppID(rctx, &plan); 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. + fmt.Fprintf(rctx.IO().ErrOut, "publishing to app %s (from %s)\n", plan.AppID, plan.AppIDSource) + + preReleasePath := fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(plan.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) + } + + zipball, err := buildAppDevZip(rctx.FileIO(), toAppDevEntries(plan.Entries)) + if err != nil { + return err + } + if limit := deploy.DefaultLimits().ZipBytes; zipball.Size > limit { + return appsFailedPreconditionError("packed zip is %s, exceeding the %s limit", deploy.HumanBytes(zipball.Size), deploy.HumanBytes(limit)). + WithHint("drop files from the payload, or narrow --dir to just the directory you want published") + } + + //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 the project mode) + 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 expires; re-run the publish") + } + + releasePath := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(plan.AppID)) + releaseData, err := rctx.CallAPITyped("POST", releasePath, nil, htmlReleaseBody(plan.ContentHash)) + 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") + if onlineURL == "" && releaseID != "" { + finalStatus, finalURL, werr := resolveAppDevReleaseOutcome(ctx, rctx, plan.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) + } + } + } + // built is always false here: the bare-HTML path publishes the files as + // they are on disk and never runs a build command. + data := htmlDeployResult(plan, zipball.FileCount, zipball.Size, releaseID, status) + pollHint := "" + if onlineURL != "" { + data["online_url"] = onlineURL + } else if releaseID != "" { + pollHint = fmt.Sprintf("lark-cli apps +release-get --app-id %s --release-id %s", plan.AppID, releaseID) + data["poll_hint"] = pollHint + } + // No spark.json writeback: this path publishes a loose file or directory + // and must not turn the caller's cwd into a project. + rctx.OutFormatRaw(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "app_id: %s\nrelease_id: %s\nstatus: %s\n", plan.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_html_test.go b/shortcuts/apps/apps_deploy_html_test.go new file mode 100644 index 0000000000..22f26cef84 --- /dev/null +++ b/shortcuts/apps/apps_deploy_html_test.go @@ -0,0 +1,633 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/apps/deploy" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestIsHTMLDeployMode(t *testing.T) { + if !isHTMLDeployMode("a.html", "", "") || !isHTMLDeployMode("", "./site", "") { + t.Error("either payload flag should select html deploy mode") + } + // --entry-file cannot stand alone, but it must still select this mode: + // otherwise a lone --entry-file falls through to the project mode and the + // user is told to scaffold a project instead of to add --dir. + if !isHTMLDeployMode("", "", "page.html") { + t.Error("--entry-file alone must select html deploy mode so its own error can surface") + } + if isHTMLDeployMode("", "", "") { + t.Error("no payload flag should keep the existing project mode") + } +} + +func TestValidateHTMLDeployFlags(t *testing.T) { + cases := []struct { + name string + filePath, dir, entry string + skipBuild, noVerify bool + wantErr string + }{ + {name: "两者同时给", filePath: "a.html", dir: "./site", wantErr: "mutually exclusive"}, + {name: "entry-file 无 dir", filePath: "a.html", entry: "p.html", wantErr: "--entry-file"}, + {name: "skip-build 误用", filePath: "a.html", skipBuild: true, wantErr: "--skip-build"}, + {name: "no-verify 误用", dir: "./site", noVerify: true, wantErr: "--no-verify"}, + {name: "file-path 非 html", filePath: "a.txt", wantErr: "--file-path"}, + {name: "entry-file 带路径分隔符", dir: "./site", entry: "sub/p.html", wantErr: "--entry-file"}, + {name: "合法单文件", filePath: "a.html"}, + {name: "合法目录带入口", dir: "./site", entry: "p.html"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateHTMLDeployFlags(tc.filePath, tc.dir, tc.entry, tc.skipBuild, tc.noVerify) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("got %v, want containing %q", err, tc.wantErr) + } + }) + } +} + +// htmlDeployRuntime builds a RuntimeContext carrying only the bare-HTML flags, +// so validateHTMLDeploy can be exercised without the spark.json project setup. +func htmlDeployRuntime(t *testing.T, filePath, dir, entry string, allowSensitive bool) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "+deploy"} + cmd.Flags().String("file-path", filePath, "") + cmd.Flags().String("dir", dir, "") + cmd.Flags().String("entry-file", entry, "") + cmd.Flags().Bool("allow-sensitive", allowSensitive, "") + cmd.Flags().Bool("skip-build", false, "") + cmd.Flags().Bool("no-verify", false, "") + return common.TestNewRuntimeContext(cmd, nil) +} + +// chdirHTMLPayload writes files (relative names -> content) under a temp dir and +// chdirs into it, since the publish flags only accept cwd-relative paths. +func chdirHTMLPayload(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + for rel, content := range files { + full := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + old, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) + return root +} + +func TestValidateHTMLDeploy(t *testing.T) { + t.Run("单文件通过", func(t *testing.T) { + chdirHTMLPayload(t, map[string]string{"report.html": "

hi

"}) + if err := validateHTMLDeploy(htmlDeployRuntime(t, "report.html", "", "", false)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("目录默认入口通过", func(t *testing.T) { + chdirHTMLPayload(t, map[string]string{ + "site/index.html": "

hi

", + "site/assets/app.css": "body{}", + }) + if err := validateHTMLDeploy(htmlDeployRuntime(t, "", "site", "", false)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("目录缺入口报错", func(t *testing.T) { + chdirHTMLPayload(t, map[string]string{"site/page.html": "

hi

"}) + err := validateHTMLDeploy(htmlDeployRuntime(t, "", "site", "", false)) + if err == nil || !strings.Contains(err.Error(), "no entry file") { + t.Fatalf("got %v, want a missing-entry error", err) + } + }) + t.Run("凭证文件拦截", func(t *testing.T) { + chdirHTMLPayload(t, map[string]string{ + "site/index.html": "

hi

", + "site/.env": "TOKEN=x", + }) + err := validateHTMLDeploy(htmlDeployRuntime(t, "", "site", "", false)) + if err == nil || !strings.Contains(err.Error(), "credential file") { + t.Fatalf("got %v, want the credential scan to reject the payload", err) + } + }) + t.Run("allow-sensitive 放行", func(t *testing.T) { + chdirHTMLPayload(t, map[string]string{ + "site/index.html": "

hi

", + "site/.env": "TOKEN=x", + }) + if err := validateHTMLDeploy(htmlDeployRuntime(t, "", "site", "", true)); err != nil { + t.Fatalf("--allow-sensitive should waive the scan: %v", err) + } + }) + t.Run("flag 组合先于文件系统检查", func(t *testing.T) { + chdirHTMLPayload(t, nil) + err := validateHTMLDeploy(htmlDeployRuntime(t, "a.html", "site", "", false)) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("got %v, want the flag conflict reported before any stat", err) + } + }) +} + +func TestHTMLAppIDFromFlagSkipsLookup(t *testing.T) { + src, id := htmlAppIDFromFlag("app_1abc") + if src != htmlAppIDSourceFlag || id != "app_1abc" { + t.Errorf("got (%v, %q), want (--app-id, app_1abc)", src, id) + } + src, id = htmlAppIDFromFlag(" ") + if src != htmlAppIDSourceLookup || id != "" { + t.Errorf("got (%v, %q), want (lookup, \"\")", src, id) + } +} + +func TestParseHasHTMLAppCreated(t *testing.T) { + exists, id := parseHasHTMLAppCreated(map[string]interface{}{ + "exists": true, "app_id": "app_x", "app_url": "https://ignored", "ccm_token": "ignored", + }) + if !exists || id != "app_x" { + t.Errorf("got (%v, %q), want (true, app_x)", exists, id) + } + if exists, id := parseHasHTMLAppCreated(map[string]interface{}{"exists": false}); exists || id != "" { + t.Errorf("got (%v, %q), want (false, \"\")", exists, id) + } + if exists, id := parseHasHTMLAppCreated(map[string]interface{}{"exists": true}); !exists || id != "" { + t.Errorf("got (%v, %q), want (true, \"\") when app_id is absent", exists, id) + } +} + +// TestHTMLAppIDLookupPath pins the idempotency endpoint and the third source, +// which the create fallback in the Execute step reports. +func TestHTMLAppIDLookupPath(t *testing.T) { + if hasHTMLAppCreatedPath != "/open-apis/spark/v1/apps/has_html_app_created" { + t.Errorf("unexpected lookup path %q", hasHTMLAppCreatedPath) + } + if htmlAppIDSourceCreate != "+create" { + t.Errorf("unexpected create source %q", htmlAppIDSourceCreate) + } +} + +// TestHTMLDeployPlanFields covers every field Execute reads off the resolved +// plan, so a missing one shows up here rather than at the call site. +func TestHTMLDeployPlanFields(t *testing.T) { + plan := htmlDeployPlan{ + AbsEntry: "/Users/me/site/index.html", + EntryRel: "index.html", + AppID: "app_x", + AppIDSource: htmlAppIDSourceLookup, + Entries: []deploy.PackEntry{{ZipPath: "output/index.html", Size: 11}}, + FileCount: 1, + TotalBytes: 11, + ZipPaths: []string{"output/index.html"}, + RouteCount: 1, + ContentHash: "abc", + Waived: []string{".env"}, + } + if plan.AbsEntry == "" || plan.EntryRel == "" || plan.AppID == "" { + t.Error("entry and app id must survive into the plan") + } + if plan.AppIDSource != htmlAppIDSourceLookup { + t.Errorf("unexpected app id source %q", plan.AppIDSource) + } + if len(plan.Entries) != 1 || plan.Entries[0].ZipPath != "output/index.html" { + t.Errorf("unexpected entries %+v", plan.Entries) + } + if plan.FileCount != 1 || plan.TotalBytes != 11 || plan.RouteCount != 1 { + t.Errorf("unexpected counters %+v", plan) + } + if len(plan.ZipPaths) != 1 || plan.ContentHash != "abc" { + t.Errorf("unexpected zip paths or hash %+v", plan) + } + if len(plan.Waived) != 1 || plan.Waived[0] != ".env" { + t.Errorf("waived credential files must stay on the plan for the stderr notice, got %v", plan.Waived) + } +} + +// TestHTMLDeployDryRunExposesOutboundPath pins the security-review release +// condition: the dry-run must echo the absolute path that actually leaves the +// machine, not just the relative entry name. +func TestHTMLDeployDryRunExposesOutboundPath(t *testing.T) { + dry := common.NewDryRunAPI() + fillHTMLDeployDryRun(dry, htmlDeployPlan{ + AbsEntry: "/Users/me/work/report.html", + EntryRel: "report.html", + AppIDSource: htmlAppIDSourceLookup, + FileCount: 2, + TotalBytes: 40, + ZipPaths: []string{"output/index.html", "output/a.css"}, + RouteCount: 1, + ContentHash: "abc", + }) + raw, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry-run: %v", err) + } + var out map[string]interface{} + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal dry-run: %v", err) + } + if out["idempotent_key"] != "/Users/me/work/report.html" { + t.Errorf("dry-run must show the absolute path sent as idempotent_key, got %v", out["idempotent_key"]) + } + if out["file_path"] != "/Users/me/work/report.html" { + t.Errorf("dry-run must show the absolute path sent as file_path, got %v", out["file_path"]) + } + if out["content_hash"] != "abc" { + t.Errorf("content_hash missing from dry-run: %v", out) + } + if out["app_id_source"] != string(htmlAppIDSourceLookup) { + t.Errorf("app_id_source = %v", out["app_id_source"]) + } +} + +func TestToAppDevEntries(t *testing.T) { + got := toAppDevEntries([]deploy.PackEntry{ + {ZipPath: "output/index.html", AbsPath: "site/index.html", Size: 11}, + {ZipPath: "output/routes.json", Content: []byte("[]"), Size: 2}, + }) + if len(got) != 2 { + t.Fatalf("got %d entries, want 2", len(got)) + } + if got[0].ZipPath != "output/index.html" || got[0].AbsPath != "site/index.html" || got[0].Size != 11 { + t.Errorf("disk entry = %+v", got[0]) + } + if got[1].AbsPath != "" || string(got[1].Content) != "[]" { + t.Errorf("generated entry = %+v", got[1]) + } +} + +func TestResolveHTMLDeployPlan(t *testing.T) { + t.Run("单文件入口改名并生成路由", func(t *testing.T) { + root := chdirHTMLPayload(t, map[string]string{"report.html": "

hi

"}) + plan, err := resolveHTMLDeployPlan(htmlDeployRuntime(t, "report.html", "", "", false)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(resolvedRoot(t, root), "report.html"); plan.AbsEntry != want { + t.Errorf("AbsEntry = %q, want %q", plan.AbsEntry, want) + } + if plan.EntryRel != "report.html" { + t.Errorf("EntryRel = %q", plan.EntryRel) + } + wantPaths := []string{"output/index.html", "output/routes.json"} + if !reflect.DeepEqual(plan.ZipPaths, wantPaths) { + t.Errorf("ZipPaths = %v, want %v", plan.ZipPaths, wantPaths) + } + if plan.FileCount != 2 || plan.RouteCount != 1 { + t.Errorf("FileCount/RouteCount = %d/%d", plan.FileCount, plan.RouteCount) + } + if plan.TotalBytes != int64(len("

hi

")) { + t.Errorf("TotalBytes = %d (raw payload bytes only, routes.json excluded)", plan.TotalBytes) + } + // Single-file payload: the fingerprint is the raw file's sha256. + sum := sha256.Sum256([]byte("

hi

")) + if plan.ContentHash != hex.EncodeToString(sum[:]) { + t.Errorf("ContentHash = %q, want the raw sha256", plan.ContentHash) + } + }) + t.Run("目录入口改名且其余文件保留路径", func(t *testing.T) { + root := chdirHTMLPayload(t, map[string]string{ + "site/page.html": "

hi

", + "site/assets/app.css": "body{}", + }) + plan, err := resolveHTMLDeployPlan(htmlDeployRuntime(t, "", "site", "page.html", false)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(resolvedRoot(t, root), "site", "page.html"); plan.AbsEntry != want { + t.Errorf("AbsEntry = %q, want %q", plan.AbsEntry, want) + } + got := append([]string(nil), plan.ZipPaths...) + sort.Strings(got) + want := []string{"output/assets/app.css", "output/index.html", "output/routes.json"} + if !reflect.DeepEqual(got, want) { + t.Errorf("ZipPaths = %v, want %v", got, want) + } + if len(plan.ContentHash) != 64 { + t.Errorf("ContentHash = %q, want a 64-char digest", plan.ContentHash) + } + }) + t.Run("凭证放行时记录 waived", func(t *testing.T) { + chdirHTMLPayload(t, map[string]string{ + "site/index.html": "

hi

", + "site/.env": "TOKEN=x", + }) + plan, err := resolveHTMLDeployPlan(htmlDeployRuntime(t, "", "site", "", true)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plan.Waived) != 1 || plan.Waived[0] != ".env" { + t.Errorf("Waived = %v", plan.Waived) + } + }) +} + +// resolvedRoot mirrors the symlink resolution the collector applies, so the +// expected absolute path matches on macOS where /var is a symlink. +func resolvedRoot(t *testing.T, root string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatalf("eval symlinks: %v", err) + } + return resolved +} + +func stubHasHTMLAppCreated(reg *httpmock.Registry, data map[string]interface{}) *httpmock.Stub { + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/has_html_app_created", + Body: map[string]interface{}{"code": float64(0), "data": data}, + } + reg.Register(stub) + return stub +} + +func TestHTMLDeployExecute_LookupHit(t *testing.T) { + root := chdirHTMLPayload(t, map[string]string{ + "site/index.html": "

hi

", + "site/app.css": "body{}", + }) + var uploaded []byte + srv := newTOSTLSServer(t, func(w http.ResponseWriter, r *http.Request) { + uploaded, _ = io.ReadAll(r.Body) + w.WriteHeader(200) + }) + factory, stdout, reg := newAppsExecuteFactory(t) + lookup := stubHasHTMLAppCreated(reg, map[string]interface{}{"exists": true, "app_id": "app_x"}) + stubPreRelease(reg, "app_x", srv.URL, nil) + release := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/releases", + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "release_id": "rel_1", "status": "finished", "online_url": "https://x/app/app_x", + }}, + } + reg.Register(release) + + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--dir", "site", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + + // The idempotency key must be the entry's absolute path. + var lookupBody map[string]interface{} + if err := json.Unmarshal(lookup.CapturedBody, &lookupBody); err != nil { + t.Fatalf("decode lookup body: %v", err) + } + wantKey := filepath.Join(resolvedRoot(t, root), "site", "index.html") + if lookupBody["idempotent_key"] != wantKey { + t.Errorf("idempotent_key = %v, want %q", lookupBody["idempotent_key"], wantKey) + } + if len(uploaded) == 0 { + t.Error("zip body not uploaded") + } + // The release carries the content fingerprint. extra is a JSON *string* on + // the wire: an object form still gets a 200 back, so only an assertion on + // the encoding catches a regression here. + var releaseBody struct { + Extra string `json:"extra"` + } + if err := json.Unmarshal(release.CapturedBody, &releaseBody); err != nil { + t.Fatalf("decode release body (extra must be a JSON string, not an object): %v", err) + } + var extra map[string]string + if err := json.Unmarshal([]byte(releaseBody.Extra), &extra); err != nil { + t.Fatalf("extra is not a JSON document: %q (%v)", releaseBody.Extra, err) + } + if len(extra["hash_tag"]) != 64 { + t.Errorf("extra.hash_tag = %q, want a 64-char digest", extra["hash_tag"]) + } + data := parseEnvelopeData(t, stdout) + if data["app_id"] != "app_x" || data["release_id"] != "rel_1" || data["online_url"] != "https://x/app/app_x" { + t.Errorf("data = %v", data) + } + if data["built"] != false { + t.Errorf("built = %v, want false on the bare HTML path", data["built"]) + } + if data["file_count"] != float64(3) { + t.Errorf("file_count = %v, want 3 (index.html + app.css + routes.json)", data["file_count"]) + } + // The bare HTML path must never create a spark.json in the payload dir. + if _, err := os.Stat(filepath.Join(root, sparkJSONRelPath)); !os.IsNotExist(err) { + t.Errorf("bare HTML publish must not write %s (stat err = %v)", sparkJSONRelPath, err) + } +} + +func TestHTMLDeployExecute_CreateFallback(t *testing.T) { + root := chdirHTMLPayload(t, map[string]string{"report.html": "

hi

"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + stubHasHTMLAppCreated(reg, map[string]interface{}{"exists": false}) + create := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps", + BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte(`"app_type"`)) }, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "app": map[string]interface{}{"app_id": "app_new"}, + }}, + } + reg.Register(create) + stubPreRelease(reg, "app_new", srv.URL, nil) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_new/releases", + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "release_id": "rel_2", "status": "publishing", + }}, + }) + + if err := runAppsShortcut(t, AppsDeploy, []string{"+deploy", "--file-path", "report.html", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(create.CapturedBody, &body); err != nil { + t.Fatalf("decode create body: %v", err) + } + if body["app_type"] != "html" { + t.Errorf("app_type = %v, want html", body["app_type"]) + } + if body["name"] != "report" { + t.Errorf("name = %v, want the entry base name", body["name"]) + } + // Creation is what registers the idempotency key, so the field name has to + // match what the lookup queries by — file_path would be silently ignored. + wantPath := filepath.Join(resolvedRoot(t, root), "report.html") + if body["idempotent_key"] != wantPath { + t.Errorf("idempotent_key = %v, want %q", body["idempotent_key"], wantPath) + } + if _, stale := body["file_path"]; stale { + t.Error("file_path must not be sent: the server registers the key under idempotent_key") + } + data := parseEnvelopeData(t, stdout) + if data["app_id"] != "app_new" || data["release_id"] != "rel_2" { + t.Errorf("data = %v", data) + } + if _, ok := data["poll_hint"]; !ok { + t.Errorf("an in-flight release must carry poll_hint: %v", data) + } +} + +func TestHTMLDeployExecute_AppIDFlagSkipsLookup(t *testing.T) { + chdirHTMLPayload(t, map[string]string{"report.html": "

hi

"}) + srv := newTOSTLSServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + factory, stdout, reg := newAppsExecuteFactory(t) + lookup := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/has_html_app_created", + Optional: true, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"exists": false}}, + } + reg.Register(lookup) + stubPreRelease(reg, "app_flag", srv.URL, nil) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_flag/releases", + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{ + "release_id": "rel_3", "status": "finished", "online_url": "https://x/app/app_flag", + }}, + }) + + args := []string{"+deploy", "--file-path", "report.html", "--app-id", "app_flag", "--as", "user"} + if err := runAppsShortcut(t, AppsDeploy, args, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + if len(lookup.CapturedBodies) != 0 { + t.Errorf("--app-id must skip the idempotency lookup, got %d call(s)", len(lookup.CapturedBodies)) + } +} + +func TestHTMLDeployDryRun_NoWrites(t *testing.T) { + root := chdirHTMLPayload(t, map[string]string{"report.html": "

hi

"}) + factory, stdout, reg := newAppsExecuteFactory(t) + args := []string{"+deploy", "--file-path", "report.html", "--as", "user", "--dry-run"} + if err := runAppsShortcut(t, AppsDeploy, args, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + reg.Verify(t) // no stub registered: a dry-run must issue no request at all + data, err := decodeDryRunDataMap(stdout.Bytes()) + if err != nil { + t.Fatalf("decode dry-run output: %v (raw=%q)", err, stdout.String()) + } + wantPath := filepath.Join(resolvedRoot(t, root), "report.html") + if data["idempotent_key"] != wantPath { + t.Errorf("idempotent_key = %v, want %q", data["idempotent_key"], wantPath) + } + // Without --app-id the target is only decided at run time. The preview must + // say so, and must name the app that would be created — apps has no + // +delete, so an unexpected create cannot be undone. + src, _ := data["app_id_source"].(string) + if !strings.Contains(src, string(htmlAppIDSourceLookup)) || + !strings.Contains(src, string(htmlAppIDSourceCreate)) { + t.Errorf("app_id_source = %v, want both the lookup and the create fallback named", data["app_id_source"]) + } + if data["app_name_if_created"] != "report" { + t.Errorf("app_name_if_created = %v, want \"report\"", data["app_name_if_created"]) + } + if data["entry_file"] != "report.html" { + t.Errorf("entry_file = %v", data["entry_file"]) + } + if _, ok := data["content_hash"].(string); !ok { + t.Errorf("content_hash missing: %v", data) + } +} + +func TestAdoptOrGenerateRoutesNoDuplicateEntry(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "routes.json"), + []byte(`[{"path":"/","file":"index.html"},{"path":"/about","file":"about.html"}]`), 0o600); err != nil { + t.Fatalf("write routes.json: %v", err) + } + entries := []deploy.PackEntry{ + {ZipPath: "output/index.html", AbsPath: filepath.Join(root, "index.html")}, + {ZipPath: "output/routes.json", AbsPath: filepath.Join(root, "routes.json")}, + } + count, err := adoptOrGenerateRoutes(htmlDeployTestFIO{}, &entries, []string{"index.html"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 2 { + t.Errorf("route count = %d, want 2 (from the payload's own routes.json)", count) + } + seen := 0 + for _, e := range entries { + if e.ZipPath == "output/routes.json" { + seen++ + } + } + // 同名条目出现两次时,服务端解压保留哪一份是未定义的。 + if seen != 1 { + t.Errorf("output/routes.json appears %d times in the zip manifest, want exactly 1", seen) + } +} + +// htmlDeployTestFIO lets the bare-HTML unit tests read absolute t.TempDir +// paths; production code goes through LocalFileIO, which is cwd-bounded. +// Defined here rather than reused from the +html-publish test files so these +// tests survive that command's planned removal. +type htmlDeployTestFIO struct{} + +func (htmlDeployTestFIO) Open(name string) (fileio.File, error) { return os.Open(name) } +func (htmlDeployTestFIO) Stat(name string) (fileio.FileInfo, error) { return os.Stat(name) } +func (htmlDeployTestFIO) ResolvePath(p string) (string, error) { return p, nil } +func (htmlDeployTestFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { + panic("Save not used in bare-HTML deploy unit tests") +} + +// A publish that could not collect everything the pages reference still +// succeeds, so the only signal a machine caller gets is what the envelope says. +// Leaving it on stderr alone hands an agent an unqualified success for a page +// that renders broken -- the exact failure this path exists to prevent. +func TestHTMLDeployResultCarriesSkippedReferences(t *testing.T) { + plan := htmlDeployPlan{ + AppID: "app_1", + SkippedDeps: []deploy.Skip{ + {Ref: "assets/logo.png", From: "index.html", Why: "the file does not exist", Kind: deploy.SkipMissing}, + }, + } + data := htmlDeployResult(plan, 3, 1024, "rel_1", "finished") + + lines, ok := data["dependencies_skipped"].([]string) + if !ok || len(lines) != 1 { + t.Fatalf("envelope must carry the skipped references, got %#v", data["dependencies_skipped"]) + } + if !strings.Contains(lines[0], "assets/logo.png") || !strings.Contains(lines[0], "index.html") { + t.Errorf("the line should name the file and the page referencing it: %q", lines[0]) + } + + clean := htmlDeployResult(htmlDeployPlan{AppID: "app_1"}, 3, 1024, "rel_1", "finished") + if _, present := clean["dependencies_skipped"]; present { + t.Errorf("a publish with nothing skipped must not carry an empty field: %#v", clean) + } +} 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..837fd55379 --- /dev/null +++ b/shortcuts/apps/apps_deploy_zip_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/zip" + "bytes" + "net/http" + "net/http/httptest" + "strings" + "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") + } +} + +// A 301, 302 or 303 turns the artifact PUT into a bodyless GET. If that GET +// answers 2xx the upload reports success while nothing was stored, and the +// release is created against an artifact that does not exist -- a failure with +// no symptom anywhere, since the status code is the only evidence the upload +// leaves. A redirect that keeps the method replays the body and is allowed. +func TestAppDevTransferClientRejectsMethodChangingRedirect(t *testing.T) { + for name, code := range map[string]int{ + "302 found": http.StatusFound, + "301 moved": http.StatusMovedPermanently, + "303 see other": http.StatusSeeOther, + "307 temporary": http.StatusTemporaryRedirect, + } { + t.Run(name, func(t *testing.T) { + var methods []string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + if r.URL.Path == "/upload" { + http.Redirect(w, r, "/moved", code) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client := newAppDevTransferClient() + client.Transport = srv.Client().Transport + req, err := http.NewRequest(http.MethodPut, srv.URL+"/upload", bytes.NewReader([]byte("zip-body"))) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := client.Do(req) + if resp != nil { + resp.Body.Close() + } + + if code == http.StatusTemporaryRedirect { + if err != nil { + t.Fatalf("a redirect that keeps the method must be followed: %v", err) + } + if len(methods) != 2 || methods[1] != http.MethodPut { + t.Errorf("the replayed request should still be a PUT, got %v", methods) + } + return + } + if err == nil { + t.Fatalf("the upload must fail rather than report success for a request that carried no body; server saw %v", methods) + } + if !strings.Contains(err.Error(), "body would be dropped") { + t.Errorf("the error should say why the redirect was refused: %v", err) + } + }) + } +} diff --git a/shortcuts/apps/apps_export.go b/shortcuts/apps/apps_export.go new file mode 100644 index 0000000000..3f8e0ef476 --- /dev/null +++ b/shortcuts/apps/apps_export.go @@ -0,0 +1,385 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "errors" + "fmt" + "io" + "mime" + "net/http" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/charcheck" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/recovery" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// exportScope is the scope this command needs. It is named once so the +// declared Scopes and the authorization fact attached on a 401 cannot drift. +const exportScope = "spark:app:read" + +// maxExportEnvelopeBytes bounds how much of a suspected JSON error envelope is +// read before classification. It matches the limit DoStream already applies to +// the error bodies it reads for status >= 400. +const maxExportEnvelopeBytes = 4096 + +// AppsExport downloads an app's source code as a zip archive. +// +// The response is a raw binary stream from the gateway (not a signed URL), so the +// body is streamed straight to disk instead of being buffered in memory. +var AppsExport = common.Shortcut{ + Service: appsService, + Command: "+export", + Description: "Export an app's source code as a zip archive", + Risk: "read", + Tips: []string{ + "Exports the last commit on the app's default branch, not the sandbox working tree: changes made in the sandbox without a checkpoint are not included.", + "Example: lark-cli apps +export --app-id --output ./src.zip", + "Example (share token): lark-cli apps +export --meta-token # for an app shared with you; you still need download permission", + "Example (omit --output): lark-cli apps +export --app-id # saves to ./.zip", + }, + Scopes: []string{exportScope}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id, e.g. app_xxx (exactly one of --app-id / --meta-token)"}, + {Name: "meta-token", Desc: "creative app share token — the LAST path segment of a /page/ link, not the full URL (exactly one of --app-id / --meta-token)"}, + {Name: "checkpoint-id", Desc: "checkpoint id to export, a positive integer (default: latest commit on the default branch)"}, + {Name: "output", Desc: "local output path, must be relative to the current directory (default: .zip in cwd)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := validateExportFlags(rctx); err != nil { + return err + } + return rejectOutputTraversal(rctx.Str("output")) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST(exportPath()). + Desc("Download the app source archive and save it to --output"). + Params(exportBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := validateExportFlags(rctx); err != nil { + return err + } + + resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: exportPath(), + Body: exportBody(rctx), + }) + if err != nil { + return classifyExportErr(err) + } + defer resp.Body.Close() + + if err := rejectExportErrorEnvelope(rctx, resp); err != nil { + return err + } + + out := strings.TrimSpace(rctx.Str("output")) + if out == "" { + out = defaultExportFilename(resp, rctx) + } + saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output").WithCause(err) + } + resolved, perr := rctx.FileIO().ResolvePath(out) + if perr != nil || resolved == "" { + resolved = out + } + + result := map[string]interface{}{ + "output": resolved, + "size_bytes": saved.Size(), + } + if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" { + result["app_id"] = appID + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Saved %s (%d bytes)\n", resolved, saved.Size()) + }) + return nil + }, +} + +// validateExportFlags is the single flag-validation entry point, shared by the +// Validate hook and Execute so a direct Execute call (as in tests, and as the +// pre-existing XOR re-check already assumed) cannot skip a check. +func validateExportFlags(rctx *common.RuntimeContext) error { + if err := requireExactlyOneExportSource(rctx); err != nil { + return err + } + if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" { + if _, err := requireAppID(appID); err != nil { + return err + } + } + // The locator is deliberately NOT checked for the "app_" prefix: this endpoint + // accepts an app id or a meta token in the same path segment and tells them + // apart server-side, exactly like +get (whose --app-id is documented as "app ID + // or meta token"). validateRealAppID belongs to the commands whose server side + // only accepts a real app id (+init / +html-publish / +release-*), not here. + if err := validateExportLocatorShape(rctx); err != nil { + return err + } + return validateExportCheckpointID(rctx.Str("checkpoint-id")) +} + +// validateExportLocatorShape rejects a share link passed where a bare identifier +// is expected, whichever flag carried it. +// +// The locator goes into a path segment, so a full URL is percent-encoded and sent +// as-is; the server then fails to resolve it and answers "app not found for the +// given meta_token". That reads as "wrong app" and sends the caller off to verify +// an app id, when the actual fix is to pass only the segment. Catching the +// shape here turns a misleading 404 into a precise, actionable local error. +// +// This checks the character shape only — never whether the value is an app id or a +// token. That distinction is the server's (see validateExportFlags). +func validateExportLocatorShape(rctx *common.RuntimeContext) error { + param := "--app-id" + value := strings.TrimSpace(rctx.Str("app-id")) + if value == "" { + param = "--meta-token" + value = strings.TrimSpace(rctx.Str("meta-token")) + } + if value == "" { + return nil + } + if err := charcheck.RejectControlChars(value, param); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err). + WithParam(param).WithCause(err) + } + if strings.ContainsAny(value, "/ \t") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "%s must be a bare app id or share token, not a URL or a path", param). + WithParam(param). + WithHint(`from an app link .../app/ or a share link .../page/, pass only the last segment`) + } + return nil +} + +// validateExportCheckpointID keeps a non-numeric --checkpoint-id from reaching the +// gateway, where it would fail during i64 binding with a message that does not name +// the flag. Zero and negatives are rejected too: the server reads 0 as "latest", +// so passing it explicitly would silently ignore the flag the caller just set. +func validateExportCheckpointID(raw string) error { + value := strings.TrimSpace(raw) + if value == "" { + return nil + } + n, err := strconv.ParseInt(value, 10, 64) + if err != nil || n <= 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--checkpoint-id must be a positive integer, got %q", value). + WithParam("--checkpoint-id"). + WithHint("omit --checkpoint-id to export the latest commit on the default branch") + } + return nil +} + +// requireExactlyOneExportSource enforces the app-id / meta-token XOR. +// +// Both empty or both set is a user error the server would also reject; failing +// here keeps the message specific about which flags conflict. +func requireExactlyOneExportSource(rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + metaToken := strings.TrimSpace(rctx.Str("meta-token")) + switch { + case appID == "" && metaToken == "": + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "one of --app-id / --meta-token is required"). + WithHint("pass --app-id for an app you own, or --meta-token from a share link") + case appID != "" && metaToken != "": + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--app-id and --meta-token are mutually exclusive"). + WithParam("--meta-token") + } + return nil +} + +// exportLookup returns the path-segment locator: --app-id and --meta-token share +// one segment and the server tells them apart by the "app_" prefix, matching how +// +get already accepts either identifier. +// +// exportPath is the app-source-export endpoint. +// +// It is a top-level action path (POST /apps/export), not /apps/:appID/...: the +// caller may hold only a meta_token (a shared creative app) and have no app_id to +// put in a path segment, so both locators travel in the request body instead. This +// also avoids the gateway swallowing a static /apps/ under the registered +// GET /apps/:appID route. +func exportPath() string { + return apiBasePath + "/apps/export" +} + +// exportBody builds the request body shared by DryRun and Execute so the dry-run +// output cannot drift from the real call. app_id / meta_token are exactly-one-of +// (validated upstream); checkpoint_id is optional. +func exportBody(rctx *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{} + if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" { + body["app_id"] = appID + } + if metaToken := strings.TrimSpace(rctx.Str("meta-token")); metaToken != "" { + body["meta_token"] = metaToken + } + if checkpointID := strings.TrimSpace(rctx.Str("checkpoint-id")); checkpointID != "" { + body["checkpoint_id"] = checkpointID + } + return body +} + +// classifyExportErr re-types the archive endpoint's HTTP failures. +// +// This endpoint returns a raw binary body, so the stream client cannot inspect a +// JSON envelope and classifies every 4xx as a transport-level NetworkError. That +// is wrong for the cases below: they are not transport problems and retrying will +// never help. Re-map them onto the taxonomy an agent can act on, keeping the +// original error as the cause. 422 is the distinguishing case — the app's code is +// not stored in git at all (static HTML apps keep artifacts in file storage), so +// the hint points at the interface that can actually serve it. +func classifyExportErr(err error) error { + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + return err + } + detail := netErr.Message + switch netErr.Code { + case http.StatusUnauthorized: + // Hand back the scope as a structured fact rather than a literal login + // command: the root presenter renders recovery, and a reduced + // distribution may not carry the command this text would name. Same + // shape the git-credential path already uses. + return recovery.Attach( + errs.NewAuthenticationError(errs.SubtypeTokenMissing, "export failed: %s", detail).WithCause(err), + recovery.UserAuthorization(exportScope), + ) + case http.StatusForbidden: + return errs.NewPermissionError(errs.SubtypePermissionDenied, "export failed: %s", detail). + WithHint("you need download permission on this app; holding a share token is not enough"). + WithCause(err) + case http.StatusNotFound: + return errs.NewAPIError(errs.SubtypeNotFound, "export failed: %s", detail). + WithHint(appIDListHint). + WithCause(err) + case http.StatusUnprocessableEntity: + return errs.NewAPIError(errs.SubtypeUnknown, "export failed: %s", detail). + WithHint("this app type keeps its code outside git; use the file storage commands (+file-list / +file-download) to fetch its artifacts"). + WithCause(err) + case http.StatusRequestEntityTooLarge: + return errs.NewAPIError(errs.SubtypeUnknown, "export failed: %s", detail). + WithHint("the archive exceeds the export size limit; clone the repository with +git-credential-init instead"). + WithCause(err) + default: + // 5xx and genuine transport failures keep the client's classification, + // including its retryable flag and log id. + return err + } +} + +// rejectExportErrorEnvelope fails the export when the body is an error envelope +// rather than the archive. +// +// The stream client only intercepts status >= 400, but the OpenAPI gateway +// reports several failures as HTTP 200 carrying an error body — either a JSON +// envelope {"code":...,"msg":...} or, when the api.status field is not wired +// through on the gateway response, a bare text/plain line the handler produced +// (e.g. "permission denied", "app not found"). Without this gate the body is +// streamed to disk as the "archive" and the command reports success — the caller +// gets a .zip that is really a short error blob, which is worse than a plain +// failure because nothing looks wrong until it is opened. Both variants were +// observed against this endpoint on a test lane. +// +// The check is a whitelist, not a blacklist: only an explicit archive +// Content-Type (application/octet-stream / application/zip) is trusted and +// streamed straight through. Everything else — JSON, text/plain, or an absent +// Content-Type — is read back (bounded at 4 KiB, the same limit DoStream uses +// for the error bodies it reads itself) and refused, because a truthful archive +// always carries an explicit binary type. Whitelisting keeps the gate robust +// against any future error Content-Type the gateway might use. +func rejectExportErrorEnvelope(rctx *common.RuntimeContext, resp *http.Response) error { + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if isArchiveContentType(contentType) { + return nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxExportEnvelopeBytes)) + if err != nil { + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "export failed while reading the response: %s", err).WithCause(err) + } + // A JSON body (or an absent Content-Type, treated as JSON-suspect like + // client.HandleResponse) goes through the shared classifier so an envelope + // becomes the same typed error a non-streaming command would raise, log id + // and all. + if contentType == "" || client.IsJSONContentType(strings.ToLower(contentType)) { + if _, classifyErr := rctx.ClassifyAPIResponse(&larkcore.ApiResp{ + StatusCode: resp.StatusCode, + Header: resp.Header, + RawBody: body, + }); classifyErr != nil { + return classifyErr + } + } + // Non-JSON body (or a JSON one that parsed clean but still isn't an archive). + // If the gateway handed back a short text/plain reason (the api.status-not- + // wired case: HTTP 200 + "permission denied" etc.), surface that text so the + // caller sees the server's reason rather than an opaque "not an archive". + // Fall back to the Content-Type when the body is empty or unreadable. + if msg := strings.TrimSpace(string(body)); msg != "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "export failed: %s", util.TruncateStr(msg, 500)) + } + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "export returned %q instead of an archive", contentTypeForMessage(contentType)) +} + +// isArchiveContentType reports whether ct is a Content-Type an export archive is +// allowed to carry. The handler emits application/octet-stream on success; +// application/zip is accepted defensively in case the gateway relabels it. +// +// The media type is parsed and matched exactly, not by substring: a substring +// check would accept a hostile/mislabeled header like +// text/plain; detail="application/zip" and stream the error body to disk as the +// "archive". Parameters (charset, etc.) are stripped before comparison. +func isArchiveContentType(ct string) bool { + mediaType, _, err := mime.ParseMediaType(ct) + if err != nil { + return false + } + return mediaType == "application/octet-stream" || mediaType == "application/zip" +} + +// contentTypeForMessage renders a missing Content-Type readably in diagnostics. +func contentTypeForMessage(contentType string) string { + if contentType == "" { + return "a body with no content type" + } + return contentType +} + +// defaultExportFilename derives the save path when --output is omitted, preferring +// the server's Content-Disposition so the archive keeps its canonical name. +func defaultExportFilename(resp *http.Response, rctx *common.RuntimeContext) string { + if name := common.ResolveDownloadFileName(resp.Header, ""); name != "" { + return name + } + if appID := strings.TrimSpace(rctx.Str("app-id")); appID != "" { + return appID + ".zip" + } + return "app-source.zip" +} diff --git a/shortcuts/apps/apps_export_test.go b/shortcuts/apps/apps_export_test.go new file mode 100644 index 0000000000..deddd3973c --- /dev/null +++ b/shortcuts/apps/apps_export_test.go @@ -0,0 +1,437 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +// exportURL is the fixed app-source-export endpoint. Locators (app_id/meta_token) +// travel in the POST body, not the URL, so the path no longer varies per case. +func exportURL() string { + return "/open-apis/spark/v1/apps/export" +} + +// archiveStub serves a raw zip body the way the gateway does for this endpoint. +// The endpoint is a fixed POST /apps/export; the lookup argument is retained only +// for call-site readability and does not affect routing. +func archiveStub(_ string, status int, body []byte, contentType, disposition string) *httpmock.Stub { + headers := http.Header{} + headers.Set("Content-Type", contentType) + if disposition != "" { + headers.Set("Content-Disposition", disposition) + } + return &httpmock.Stub{ + Method: "POST", URL: exportURL(), Status: status, RawBody: body, Headers: headers, + } +} + +// TestAppsExport_RequiresExactlyOneSource pins the --app-id / --meta-token XOR. +func TestAppsExport_RequiresExactlyOneSource(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"neither", []string{"+export", "--as", "user"}}, + {"both", []string{"+export", "--app-id", "app_x", "--meta-token", "tok", "--as", "user"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsExport, c.args, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + }) + } +} + +// TestAppsExport_RejectsOutputTraversal keeps writes inside the working directory. +func TestAppsExport_RejectsOutputTraversal(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "../escape.zip", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--output" { + t.Fatalf("Param = %q, want --output", ve.Param) + } +} + +// TestAppsExport_DryRun asserts the method, URL and params without a real call. +func TestAppsExport_DryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--checkpoint-id", "42", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env dryRunAPIEnvelope + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Method != "POST" || env.API[0].URL != exportURL() { + t.Fatalf("dry-run = %s %s, want POST %s", env.API[0].Method, env.API[0].URL, exportURL()) + } + out := stdout.String() + for _, want := range []string{"app_x", "42"} { + if !strings.Contains(out, want) { + t.Errorf("dry-run output missing %q\n%s", want, out) + } + } +} + +// TestAppsExport_StreamsArchiveToDisk is the happy path: raw body lands on disk. +func TestAppsExport_StreamsArchiveToDisk(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte("ZIPDATA"), "application/octet-stream", "")) + + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "src.zip", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + b, err := os.ReadFile(filepath.Join(dir, "src.zip")) + if err != nil { + t.Fatalf("read output file: %v", err) + } + if string(b) != "ZIPDATA" { + t.Fatalf("archive content = %q, want ZIPDATA", b) + } + if !strings.Contains(stdout.String(), `"size_bytes": 7`) { + t.Errorf("output json missing size_bytes:7\n%s", stdout.String()) + } +} + +// TestAppsExport_RejectsJSONEnvelopeBody pins the gateway's HTTP 200 + JSON +// error envelope: the stream client only intercepts status >= 400, so without a +// content-type gate the envelope is written to disk as the "archive" and the +// command reports success. The caller then holds an unopenable .zip. +func TestAppsExport_RejectsJSONEnvelopeBody(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, + []byte(`{"code":40400,"msg":"app not found"}`), "application/json; charset=utf-8", "")) + + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "src.zip", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatal("execute err = nil, want the envelope surfaced as an error") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("err = %T %v, want *errs.APIError carrying the envelope code", err, err) + } + if apiErr.Code != 40400 { + t.Errorf("code = %d, want 40400 from the envelope", apiErr.Code) + } + if _, statErr := os.Stat(filepath.Join(dir, "src.zip")); !os.IsNotExist(statErr) { + t.Error("src.zip was written; a JSON error envelope must never become a product") + } +} + +// TestAppsExport_RejectsEmptyContentType covers the same gate for a response +// that omits Content-Type: the repo treats an absent type as JSON-suspect +// (see client.HandleResponse), so it must not stream straight to disk either. +func TestAppsExport_RejectsEmptyContentType(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte(`{"code":40400,"msg":"app not found"}`), "", "")) + + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "src.zip", "--as", "user"}, factory, stdout); err == nil { + t.Fatal("execute err = nil, want the envelope surfaced as an error") + } + if _, statErr := os.Stat(filepath.Join(dir, "src.zip")); !os.IsNotExist(statErr) { + t.Error("src.zip was written despite an untyped JSON body") + } +} + +// TestAppsExport_RejectsPlainTextBodyOn200 covers the exact failure observed on +// a test lane: when the api.status response field is not wired through, the +// gateway returns HTTP 200 carrying the handler's bare text/plain reason +// ("permission denied", "app not found for the given meta_token") instead of +// mapping it to a 4xx. The whitelist gate must refuse it — a text/plain body is +// never a valid archive — and surface the server's reason rather than saving it. +func TestAppsExport_RejectsPlainTextBodyOn200(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {"permission denied", "permission denied"}, + {"meta token not found", "app not found for the given meta_token"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte(tc.body), "text/plain; charset=utf-8", "")) + + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "src.zip", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatal("execute err = nil, want the plain-text error surfaced") + } + if !strings.Contains(err.Error(), tc.body) { + t.Errorf("err = %v, want it to carry the server reason %q", err, tc.body) + } + if _, statErr := os.Stat(filepath.Join(dir, "src.zip")); !os.IsNotExist(statErr) { + t.Error("src.zip was written; a text/plain error body must never become a product") + } + }) + } +} + +// TestAppsExport_RejectsSpoofedArchiveContentType guards the media-type match: +// a hostile/mislabeled header like text/plain; detail="application/zip" must not +// pass the archive whitelist via substring matching. Only the exact media type +// (parameters stripped) counts, so this error body is refused, not saved. +func TestAppsExport_RejectsSpoofedArchiveContentType(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte("permission denied"), + `text/plain; detail="application/zip"`, "")) + + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "src.zip", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatal("execute err = nil, want the spoofed-content-type body refused") + } + if _, statErr := os.Stat(filepath.Join(dir, "src.zip")); !os.IsNotExist(statErr) { + t.Error("src.zip was written; application/zip inside a text/plain parameter must not pass the whitelist") + } +} + +// TestAppsExport_DefaultsOutputToContentDisposition prefers the server-provided +// filename so the archive keeps its canonical name. +func TestAppsExport_DefaultsOutputToContentDisposition(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte("ZIP"), "application/octet-stream", `attachment; filename="app_x.zip"`)) + + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if _, err := os.Stat(filepath.Join(dir, "app_x.zip")); err != nil { + t.Fatalf("expected app_x.zip from Content-Disposition: %v", err) + } +} + +// TestAppsExport_MetaTokenSource sends the share token instead of an app id. +// +// The token occupies the same path segment an app id would — the server tells +// them apart by the "app_" prefix — so this also pins that neither source is +// ever sent as a query parameter. +func TestAppsExport_MetaTokenSource(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + var gotURL, gotBody string + stub := archiveStub("share-tok", 200, []byte("ZIP"), "application/octet-stream", "") + stub.OnMatch = func(req *http.Request) { + gotURL = req.URL.String() + if req.Body != nil { + b, _ := io.ReadAll(req.Body) + gotBody = string(b) + } + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--meta-token", "share-tok", "--output", "s.zip", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + // 定位符走 POST body 的 meta_token 字段,不进 URL path、也不进 query。 + if !strings.Contains(gotURL, "/apps/export") { + t.Fatalf("request URL = %q, want the fixed /apps/export endpoint", gotURL) + } + if strings.Contains(gotURL, "share-tok") { + t.Errorf("request URL = %q, must not carry the locator in the path", gotURL) + } + if !strings.Contains(gotBody, `"meta_token":"share-tok"`) { + t.Fatalf("request body = %q, want meta_token in the JSON body", gotBody) + } + if strings.Contains(gotBody, `"app_id"`) { + t.Errorf("request body = %q, must not carry app_id when only --meta-token is given", gotBody) + } +} + +// errHint pulls the recovery hint off whichever typed error this endpoint returned. +func errHint(err error) string { + var ae *errs.APIError + if errors.As(err, &ae) { + return ae.Hint + } + var pe *errs.PermissionError + if errors.As(err, &pe) { + return pe.Hint + } + var ne *errs.NetworkError + if errors.As(err, &ne) { + return ne.Hint + } + var authErr *errs.AuthenticationError + if errors.As(err, &authErr) { + return authErr.Hint + } + return "" +} + +// TestAppsExport_ClassifiesFailures asserts the typed error and, for the two +// cases an agent cannot otherwise recover from, that the hint says what to do +// instead. 422 is the static-HTML gate: the code is not in git at all, so the +// hint must point at file storage rather than suggest a retry. +func TestAppsExport_ClassifiesFailures(t *testing.T) { + cases := []struct { + name string + status int + body string + assert func(error) bool + wantHint string + }{ + {"unauthorized", 401, "auth info is empty", func(e error) bool { + var t *errs.AuthenticationError + return errors.As(e, &t) + }, ""}, + {"forbidden", 403, "permission denied", func(e error) bool { + var t *errs.PermissionError + return errors.As(e, &t) + }, "download permission"}, + {"not found", 404, "app not found", func(e error) bool { + var t *errs.APIError + return errors.As(e, &t) + }, ""}, + {"code not in git", 422, "this app type stores code outside git", func(e error) bool { + var t *errs.APIError + return errors.As(e, &t) + }, "file storage"}, + {"too large", 413, "archive too large", func(e error) bool { + var t *errs.APIError + return errors.As(e, &t) + }, "git-credential-init"}, + {"server error", 500, "boom", func(e error) bool { + var t *errs.NetworkError + return errors.As(e, &t) + }, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", c.status, []byte(c.body), "text/plain", "")) + + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--output", "o.zip", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("HTTP %d: expected an error", c.status) + } + if !c.assert(err) { + t.Fatalf("HTTP %d: err = %T (%v), wrong typed error", c.status, err, err) + } + if c.wantHint != "" && !strings.Contains(errHint(err), c.wantHint) { + t.Errorf("HTTP %d: hint = %q, want it to mention %q", c.status, errHint(err), c.wantHint) + } + // A failed export must not leave a partial file behind. + if _, statErr := os.Stat("o.zip"); statErr == nil { + t.Errorf("HTTP %d: output file was written despite failure", c.status) + } + }) + } +} + +// TestAppsExport_AcceptsTokenPassedAsAppID pins that --app-id is NOT client-side +// rejected for lacking the "app_" prefix. The server decides how to resolve the +// value; the CLI must not be stricter than the API (same leniency as +get, whose +// --app-id is documented as "app ID or meta token"). The value now travels in the +// request body's app_id field rather than a path segment. +func TestAppsExport_AcceptsTokenPassedAsAppID(t *testing.T) { + chdirTemp(t) + token := "DemoPageTokenAbCdEf123456" + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub(token, 200, []byte("ZIPDATA"), "application/octet-stream", "")) + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", token, "--output", "src.zip", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("Execute() = %v", err) + } +} + +// TestAppsExport_RejectsLinkAsLocator keeps a full URL from being percent-encoded +// into the locator segment, where the server answers "app not found for the given +// meta_token" — a 404 that reads as "wrong app" and sends the caller off verifying +// app ids instead of trimming the URL. Checked on whichever flag carried it. +func TestAppsExport_RejectsLinkAsLocator(t *testing.T) { + cases := []struct { + name string + flag string + value string + }{ + {"share url via meta-token", "--meta-token", "https://x.feishu.cn/page/DemoPageTokenAbCdEf1"}, + {"path fragment via meta-token", "--meta-token", "page/DemoPageTokenAbCdEf1"}, + {"inner space via meta-token", "--meta-token", "Demo Token"}, + {"app url via app-id", "--app-id", "https://x.feishu.cn/app/app_demo"}, + {"path fragment via app-id", "--app-id", "app/app_demo"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsExport, + []string{"+export", c.flag, c.value, "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != c.flag { + t.Fatalf("Param = %q, want %s", ve.Param, c.flag) + } + // The recovery must say "pass only the last segment"; "app not found" + // is exactly the wrong lesson for this input. + if !strings.Contains(ve.Hint, "last segment") { + t.Fatalf("Hint = %q, want it to point at the last segment", ve.Hint) + } + }) + } +} + +// TestAppsExport_RejectsInvalidCheckpointID keeps a non-numeric or non-positive +// checkpoint id from reaching the gateway, where i64 binding fails with a message +// that does not name the flag. Zero is rejected because the server reads it as +// "latest", silently ignoring the flag the caller just set. +func TestAppsExport_RejectsInvalidCheckpointID(t *testing.T) { + for _, value := range []string{"abc", "0", "-1", "1.5"} { + t.Run(value, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--checkpoint-id", value, "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--checkpoint-id" { + t.Fatalf("Param = %q, want --checkpoint-id", ve.Param) + } + }) + } +} + +// TestAppsExport_AcceptsValidCheckpointID guards the validator against being so +// strict it blocks the happy path. +func TestAppsExport_AcceptsValidCheckpointID(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(archiveStub("app_x", 200, []byte("ZIPDATA"), "application/octet-stream", "")) + if err := runAppsShortcut(t, AppsExport, + []string{"+export", "--app-id", "app_x", "--checkpoint-id", "42", "--output", "src.zip", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("Execute() = %v", err) + } +} 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..33594a54b7 --- /dev/null +++ b/shortcuts/apps/apps_init_template.go @@ -0,0 +1,301 @@ +// 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 + } + if err := validateAppDevDir(rctx.Str("dir")); err != nil { + return err + } + // The lexical check above cannot see a symbolic link. A relative link + // passes it and still redirects every scaffold write outside the + // workspace, so the target goes through the runtime's own path + // validation, which resolves links before deciding. A target that does + // not exist yet is the normal case and stays allowed. + if err := rctx.ValidatePath(resolveAppDevDir(rctx.Str("dir"))); err != nil { + return appsValidationParamError("--dir", + "--dir %q does not resolve inside the current directory", resolveAppDevDir(rctx.Str("dir"))). + WithHint("scaffolding writes into this directory; point it at a real path under the current directory, not at a link out of it") + } + return nil + }, + 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..f778cb9ec0 --- /dev/null +++ b/shortcuts/apps/apps_init_template_test.go @@ -0,0 +1,862 @@ +// 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) + } +} + +// A symbolic link passes the lexical --dir checks and still sends every +// scaffold write outside the workspace. The name looks ordinary and relative, +// so nothing about the invocation hints at where the files actually land. +func TestAppsInitTemplate_RejectsSymlinkedDirOutsideWorkspace(t *testing.T) { + outside := t.TempDir() + work := t.TempDir() + if err := os.Symlink(outside, filepath.Join(work, "project")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(work); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + + err = AppsInitTemplate.Validate(context.Background(), testRuntimeAppDevInit(t, "frontend", "project")) + if err == nil { + t.Fatal("--dir pointing at a link out of the workspace must be rejected") + } + if !strings.Contains(err.Error(), "--dir") { + t.Errorf("the error should name the flag at fault: %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/deploy/collect.go b/shortcuts/apps/deploy/collect.go new file mode 100644 index 0000000000..1292e280cf --- /dev/null +++ b/shortcuts/apps/deploy/collect.go @@ -0,0 +1,240 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "errors" + "io/fs" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" +) + +// Candidate is one file of the payload as collected from disk. +type Candidate struct { + RelPath string + AbsPath string + Size int64 + // Via names the file that referenced this one, empty for the entry and for + // everything a --dir walk picks up. A dependency the caller never wrote + // down is hard to reason about when it turns out to be a problem, so the + // diagnostics need to be able to say where it came from. + Via string +} + +// isUnsafeRel reports whether a forward-slash relative path must never be +// written into a zip header: absolute, containing a .. component, or holding a +// NUL byte. Component-aware, so names that merely contain ".." as a substring +// (archive.tar..bak) stay allowed. +func isUnsafeRel(rel string) bool { + // A literal backslash never appears in a path this walker produces + // (filepath.ToSlash already normalized real separators), so its only + // source is a file whose name contains one. Reject it: an unpacker that + // applies Windows semantics would read it as a separator, which is a + // zip-slip primitive. Defense in depth — the server's unpacker is not + // ours to verify. + return strings.Contains(rel, `\`) || + strings.HasPrefix(rel, "/") || + rel == ".." || + strings.HasPrefix(rel, "../") || + strings.Contains(rel, "/../") || + strings.HasSuffix(rel, "/..") || + strings.ContainsRune(rel, 0) +} + +// canonicalAbs resolves relPath to an absolute path with symlinks evaluated, +// matching what SafeInputPath produces internally. Plain filepath.Abs is not +// enough: on macOS /tmp is a symlink to /private/tmp, so the same file reached +// through the two spellings would otherwise yield two different idempotency +// keys and create two apps. +func canonicalAbs(relPath string) (string, error) { + //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); relPath already passed FileIO.Stat's input validation, and FileIO.ResolvePath validates output paths only. + abs, err := filepath.Abs(relPath) + if err != nil { + return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve %q: %v", relPath, err).WithCause(err) + } + //nolint:forbidigo // same rationale as filepath.Abs above; the target exists because the caller already stat-ed it. + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve symlinks for %q: %v", relPath, err).WithCause(err) + } + return resolved, nil +} + +// inputPathError re-frames the sandbox rejection that FileIO.Stat returns. +// Left unwrapped it surfaces as an internal error naming --file — a flag this +// command does not have — and suggests reading out-of-tree content from stdin, +// which does not apply to a publish payload. Callers pass their own flag name. +func inputPathError(param, path string, cause error) error { + // Only claim the path is out of bounds when that is actually what we + // determined. Earlier revisions used that sentence as the catch-all, so a + // permission error, a "not a directory" from a bad join, or a symlink loop + // on a plain ./relative path all told the caller to cd — which changes + // nothing and, for an agent assembling paths, invites useless retries. + switch { + case errors.Is(cause, fs.ErrNotExist): + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "%s %q does not exist", param, path). + WithParam(param). + WithCause(cause). + WithHint("check the path; it is resolved relative to the current directory") + + case errors.Is(cause, fs.ErrPermission): + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "%s %q cannot be read: permission denied", param, path). + WithParam(param). + WithCause(cause). + WithHint("check the permissions on the path and every directory above it") + + case escapesWorkingDir(path): + // The cause is attached but never interpolated: its text names --file + // and offers a stdin fallback, neither of which exists here. + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "%s %q is outside the current directory: the path must be relative and must resolve inside it", param, path). + WithParam(param). + WithCause(cause). + WithHint("cd to the directory that holds the payload, then pass a relative path") + + default: + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "%s %q cannot be used", param, path). + WithParam(param). + WithCause(cause). + WithHint("check that the path points at a readable file or directory inside the current directory") + } +} + +// escapesWorkingDir reports whether the input itself is out of bounds — an +// absolute path, or one that climbs above the current directory. Judged from +// the input rather than from the sandbox error so the message only makes this +// claim when it is true. +func escapesWorkingDir(path string) bool { + if filepath.IsAbs(path) { + return true + } + cleaned := filepath.ToSlash(filepath.Clean(path)) + return cleaned == ".." || strings.HasPrefix(cleaned, "../") +} + +// CollectFile resolves a single-file payload: the entry file plus the local +// files it references, transitively. Publishing the page alone would ship a +// document whose stylesheet, scripts and images all 404 -- the artifact would +// be broken, not merely different from what the web client produces. +// +// The walk mirrors the web client's collector, because the publish carries a +// fingerprint of the resulting file set and the GUI compares it against the set +// it would have built itself. Anything the two disagree about surfaces as a +// page the GUI reports as permanently out of sync, with no error to explain it. +// +// relPath goes through the caller's FileIO so the cwd sandbox check runs. The +// entry's resolved absolute path is returned for use as the idempotency key. +// The third return value lists references that were found but not published, +// so the caller can say so instead of silently shipping a page with holes. +func CollectFile(fio fileio.FileIO, relPath string) ([]Candidate, string, []Skip, error) { + st, err := fio.Stat(relPath) + if err != nil { + return nil, "", nil, inputPathError("--file-path", relPath, err) + } + if !st.Mode().IsRegular() { + return nil, "", nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "--file-path %q is not a regular file", relPath).WithParam("--file-path") + } + abs, err := canonicalAbs(relPath) + if err != nil { + return nil, "", nil, err + } + // The payload root is the entry's directory, resolved on its own rather + // than taken from the entry's own resolved path: if the entry is a symlink, + // its target lives elsewhere, and dependencies are written relative to + // where the page sits, not to where the link points. + rootDir := filepath.Dir(relPath) + rootAbs, err := canonicalAbs(rootDir) + if err != nil { + return nil, "", nil, err + } + c := &collector{ + fio: fio, + root: rootDir, + rootAbs: rootAbs, + entryRel: filepath.Base(relPath), + validated: map[string]bool{}, + } + if err := c.walk(); err != nil { + return nil, "", nil, err + } + return c.cands, abs, c.skipped, nil +} + +// CollectDir resolves a directory payload. It returns the candidates, the file +// names sitting directly at the directory root (for entry resolution) and the +// directory's absolute path. +func CollectDir(fio fileio.FileIO, relDir string) ([]Candidate, []string, string, error) { + st, err := fio.Stat(relDir) + if err != nil { + return nil, nil, "", inputPathError("--dir", relDir, err) + } + if !st.IsDir() { + return nil, nil, "", errs.NewValidationError(errs.SubtypeFailedPrecondition, + "--dir %q is not a directory; use --file-path to publish a single file", relDir).WithParam("--dir") + } + cands, rootNames, _, err := collectDirAt(relDir) + if err != nil { + return nil, nil, "", err + } + abs, err := canonicalAbs(relDir) + if err != nil { + return nil, nil, "", err + } + return cands, rootNames, abs, nil +} + +// collectDirAt walks root and returns every regular file as a candidate. +// Symlinks are not followed and a .git entry skips its whole subtree. +func collectDirAt(root string) ([]Candidate, []string, string, error) { + var cands []Candidate + var rootNames []string + //nolint:forbidigo // the repository forbids direct filesystem calls, but fileio exposes no WalkDir; root is validated by the caller's fio.Stat. + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return errs.NewInternalError(errs.SubtypeFileIO, "walk %q: %v", path, walkErr).WithCause(walkErr) + } + if d.Name() == ".git" { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return errs.NewInternalError(errs.SubtypeFileIO, "stat %q: %v", path, err).WithCause(err) + } + // Regular files only: symlinks, devices, pipes and sockets are skipped + // so a link cannot pull content from outside the payload root. + if !info.Mode().IsRegular() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return errs.NewInternalError(errs.SubtypeFileIO, "relativize %q: %v", path, err).WithCause(err) + } + relSlash := filepath.ToSlash(rel) + if isUnsafeRel(relSlash) { + return errs.NewInternalError(errs.SubtypeUnknown, "unsafe relative path %q for %s", relSlash, path) + } + if !strings.Contains(relSlash, "/") { + rootNames = append(rootNames, relSlash) + } + cands = append(cands, Candidate{RelPath: relSlash, AbsPath: path, Size: info.Size()}) + return nil + }) + if err != nil { + return nil, nil, "", err + } + return cands, rootNames, root, nil +} diff --git a/shortcuts/apps/deploy/collect_test.go b/shortcuts/apps/deploy/collect_test.go new file mode 100644 index 0000000000..ca94c05267 --- /dev/null +++ b/shortcuts/apps/deploy/collect_test.go @@ -0,0 +1,149 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/larksuite/cli/errs" +) + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +func TestIsUnsafeRel(t *testing.T) { + unsafe := []string{"/abs", "..", "../x", "a/../../b", "a/..", "a\x00b", `a\\b.html`} + for _, in := range unsafe { + if !isUnsafeRel(in) { + t.Errorf("isUnsafeRel(%q) = false, want true", in) + } + } + safe := []string{"index.html", "a/b.css", "archive.tar..bak"} + for _, in := range safe { + if isUnsafeRel(in) { + t.Errorf("isUnsafeRel(%q) = true, want false", in) + } + } +} + +func TestCollectDirSkipsGitAndNonRegular(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "index.html"), "hi") + mustWrite(t, filepath.Join(root, "assets", "x.css"), "c") + mustWrite(t, filepath.Join(root, ".git", "config"), "g") + if err := os.Symlink(filepath.Join(root, "index.html"), filepath.Join(root, "link.html")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + cands, rootNames, _, err := collectDirAt(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := map[string]bool{} + for _, c := range cands { + got[c.RelPath] = true + } + if !got["index.html"] || !got["assets/x.css"] { + t.Errorf("missing expected files: %v", got) + } + if got[".git/config"] { + t.Error(".git subtree must be skipped") + } + if got["link.html"] { + t.Error("symlinks must not be followed") + } + if len(rootNames) == 0 { + t.Error("rootNames must list the directory's top-level file names") + } +} + +func TestCanonicalAbsResolvesSymlinks(t *testing.T) { + real := t.TempDir() + mustWrite(t, filepath.Join(real, "index.html"), "hi") + + linkDir := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(real, linkDir); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + viaReal, err := canonicalAbs(filepath.Join(real, "index.html")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + viaLink, err := canonicalAbs(filepath.Join(linkDir, "index.html")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // 同一个文件经由符号链接与真实路径进入,必须得到同一个 key, + // 否则会为同一份内容建出两个应用。 + if viaReal != viaLink { + t.Errorf("canonicalAbs diverged: %q vs %q", viaReal, viaLink) + } +} + +func TestInputPathErrorOnlyClaimsOutOfBoundsWhenTrue(t *testing.T) { + // 核心属性:只有输入本身确实越界时,才可以说「必须相对且在当前目录内」。 + // 对一个 ./ 开头、就在 cwd 内的路径说这句话,会把调用方引向无效的 cd 重试。 + const outOfBounds = "outside the current directory" + + cases := []struct { + name string + path string + cause error + wantContains string + mustNotMention bool // 不得出现越界断言 + }{ + {"不存在", "./nope.html", fs.ErrNotExist, "does not exist", true}, + {"无权限", "./locked/page.html", fs.ErrPermission, "permission denied", true}, + {"路径拼接错(ENOTDIR)", "./page.html/deeper.html", syscall.ENOTDIR, "cannot be used", true}, + {"软链环(ELOOP)", "./loop.html", syscall.ELOOP, "cannot be used", true}, + {"绝对路径", "/etc/passwd", errors.New("resolves outside"), outOfBounds, false}, + {"向上穿透", "../outside.html", errors.New("resolves outside"), outOfBounds, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := inputPathError("--file-path", tc.path, tc.cause) + ve := requireValidation(t, err, errs.SubtypeFailedPrecondition) + if ve.Param != "--file-path" { + t.Errorf("Param = %q, want --file-path", ve.Param) + } + // The wrapped failure has to stay reachable: it is what tells a + // caller reading the envelope whether this was ENOENT or EACCES, + // which the re-framed message deliberately no longer spells out. + requireCause(t, err, tc.cause) + if !strings.Contains(ve.Message, tc.wantContains) { + t.Errorf("error = %q, want it to contain %q", ve.Message, tc.wantContains) + } + if tc.mustNotMention && strings.Contains(ve.Message, outOfBounds) { + t.Errorf("a path inside the working directory must not be reported as out of bounds: %q", ve.Message) + } + }) + } +} + +func TestEscapesWorkingDir(t *testing.T) { + for _, p := range []string{"/abs/x.html", "../up.html", "..", "a/../../up.html"} { + if !escapesWorkingDir(p) { + t.Errorf("escapesWorkingDir(%q) = false, want true", p) + } + } + for _, p := range []string{"./x.html", "a/b.html", "a/../b.html", "."} { + if escapesWorkingDir(p) { + t.Errorf("escapesWorkingDir(%q) = true, want false", p) + } + } +} diff --git a/shortcuts/apps/deploy/contenthash.go b/shortcuts/apps/deploy/contenthash.go new file mode 100644 index 0000000000..f116292e9b --- /dev/null +++ b/shortcuts/apps/deploy/contenthash.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package deploy holds the pure logic behind publishing a bare HTML file or +// directory as a Miaoda app: payload collection, entry resolution, guards, +// zip manifest and the content fingerprint. It is deliberately independent +// of the +html-publish implementation so the two can evolve separately. +package deploy + +import ( + "crypto/sha256" + "encoding/hex" + "path/filepath" + "sort" + "strconv" + "strings" + "unicode/utf16" + + "github.com/larksuite/cli/errs" +) + +// HashFile is one file taking part in the content fingerprint. Path uses the +// published-path convention (the entry is recorded as index.html, everything +// else relative to the entry's directory); Raw is the file's original bytes. +type HashFile struct { + Path string + Raw []byte +} + +// hashedExts are fingerprinted by content. Anything outside this set (images, +// fonts, svg, other binaries) contributes its byte count instead, matching the +// algorithm the web client uses. +var hashedExts = map[string]bool{ + "css": true, "htm": true, "html": true, "js": true, "json": true, "mjs": true, +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// utf16Less compares by UTF-16 code unit, matching JavaScript's default +// Array.sort(). Go's native string comparison is UTF-8 byte order, which +// disagrees on supplementary-plane characters. +func utf16Less(a, b string) bool { + ua, ub := utf16.Encode([]rune(a)), utf16.Encode([]rune(b)) + for i := 0; i < len(ua) && i < len(ub); i++ { + if ua[i] != ub[i] { + return ua[i] < ub[i] + } + } + return len(ua) < len(ub) +} + +func sortPathsUTF16(paths []string) []string { + sort.Slice(paths, func(i, j int) bool { return utf16Less(paths[i], paths[j]) }) + return paths +} + +// signatureEntry keeps path before signature, the order the web client's +// object literal produces and therefore the order JSON.stringify emits. +type signatureEntry struct { + Path string + Signature string +} + +// hexDigits is lowercase because that is what JSON.stringify emits for the +// escapes it does produce. +const hexDigits = "0123456789abcdef" + +// appendJSONString writes s the way JavaScript's JSON.stringify writes a +// string. encoding/json cannot be used even with SetEscapeHTML(false): Go also +// escapes U+2028 and U+2029, which JSON.stringify leaves literal. A file name +// carrying either character would produce a different digest and leave the GUI +// permanently reporting the content as out of sync -- with no error anywhere to +// explain it. The rest of the rules match, but they are written out here rather +// than relied upon, since the whole value of this function is being byte-exact. +func appendJSONString(dst []byte, s string) []byte { + dst = append(dst, '"') + for _, r := range s { + switch r { + case '"': + dst = append(dst, '\\', '"') + case '\\': + dst = append(dst, '\\', '\\') + case '\b': + dst = append(dst, '\\', 'b') + case '\f': + dst = append(dst, '\\', 'f') + case '\n': + dst = append(dst, '\\', 'n') + case '\r': + dst = append(dst, '\\', 'r') + case '\t': + dst = append(dst, '\\', 't') + default: + if r < 0x20 { + dst = append(dst, '\\', 'u', '0', '0', hexDigits[r>>4], hexDigits[r&0xF]) + continue + } + dst = append(dst, string(r)...) + } + } + return append(dst, '"') +} + +// marshalSignatures renders the array exactly as JSON.stringify would: no +// whitespace, keys in literal order. +func marshalSignatures(entries []signatureEntry) []byte { + out := make([]byte, 0, 64*len(entries)) + out = append(out, '[') + for i, e := range entries { + if i > 0 { + out = append(out, ',') + } + out = append(out, `{"path":`...) + out = appendJSONString(out, e.Path) + out = append(out, `,"signature":`...) + out = appendJSONString(out, e.Signature) + out = append(out, '}') + } + return append(out, ']') +} + +// ContentHash returns the fingerprint for the given file set: a single file is +// hashed from its raw bytes; two or more go through the [{path,signature}] +// digest. Callers must exclude CLI-generated files such as routes.json. +func ContentHash(files []HashFile) (string, error) { + switch len(files) { + case 0: + return "", errs.NewInternalError(errs.SubtypeUnknown, "content hash needs at least one file") + case 1: + return sha256Hex(files[0].Raw), nil + } + + entries := make([]signatureEntry, 0, len(files)) + for _, f := range files { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(f.Path), ".")) + sig := strconv.Itoa(len(f.Raw)) + if hashedExts[ext] { + sig = sha256Hex(f.Raw) + } + entries = append(entries, signatureEntry{Path: f.Path, Signature: sig}) + } + sort.Slice(entries, func(i, j int) bool { return utf16Less(entries[i].Path, entries[j].Path) }) + + return sha256Hex(marshalSignatures(entries)), nil +} diff --git a/shortcuts/apps/deploy/contenthash_golden_test.go b/shortcuts/apps/deploy/contenthash_golden_test.go new file mode 100644 index 0000000000..dc8d18294c --- /dev/null +++ b/shortcuts/apps/deploy/contenthash_golden_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "encoding/base64" + "encoding/json" + "os" + "testing" +) + +// goldenCase is one fingerprint produced by the web client's own code. +type goldenCase struct { + Files []struct { + Path string `json:"path"` + Base64 string `json:"base64"` + } `json:"files"` + Hash string `json:"hash"` +} + +// TestContentHashMatchesWebClientGolden pins the fingerprint against values +// generated by running the web client's local-html-share module itself, not by +// re-deriving the algorithm in Go. Anything the two implementations could +// disagree on -- UTF-16 versus UTF-8 path order, HTML escaping inside +// JSON.stringify, which extensions hash by content, how an extension is read +// off a path -- shows up here as a mismatched digest rather than as a GUI that +// silently reports "out of sync" forever. +// +// The values come from executing the web client's own local-html-share module +// under Node (type stripping is enough -- the module is erasable TypeScript), +// feeding it the same file sets. Regenerating therefore needs that module; do +// not hand-edit the digests, and do not regenerate them from this Go code, +// which would turn the test into a tautology. +func TestContentHashMatchesWebClientGolden(t *testing.T) { + raw, err := os.ReadFile("testdata/contenthash_golden.json") + if err != nil { + t.Fatalf("read golden: %v", err) + } + var cases map[string]goldenCase + if err := json.Unmarshal(raw, &cases); err != nil { + t.Fatalf("parse golden: %v", err) + } + if len(cases) == 0 { + t.Fatal("golden file has no cases") + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + files := make([]HashFile, 0, len(c.Files)) + for _, f := range c.Files { + b, err := base64.StdEncoding.DecodeString(f.Base64) + if err != nil { + t.Fatalf("decode %q: %v", f.Path, err) + } + files = append(files, HashFile{Path: f.Path, Raw: b}) + } + got, err := ContentHash(files) + if err != nil { + t.Fatalf("ContentHash: %v", err) + } + if got != c.Hash { + t.Errorf("digest differs from the web client\n got %s\nwant %s", got, c.Hash) + } + }) + } +} diff --git a/shortcuts/apps/deploy/contenthash_test.go b/shortcuts/apps/deploy/contenthash_test.go new file mode 100644 index 0000000000..969bc5cfcf --- /dev/null +++ b/shortcuts/apps/deploy/contenthash_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import "testing" + +func TestContentHashSingleFile(t *testing.T) { + // sha256("hello") + got, err := ContentHash([]HashFile{{Path: "index.html", Raw: []byte("hello")}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestContentHashMultiFileSignatureKinds(t *testing.T) { + files := []HashFile{ + {Path: "index.html", Raw: []byte("

hi

")}, + {Path: "logo.png", Raw: []byte{0x89, 0x50, 0x4e, 0x47}}, + } + got, err := ContentHash(files) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := sha256Hex([]byte( + `[{"path":"index.html","signature":"` + sha256Hex([]byte("

hi

")) + `"},` + + `{"path":"logo.png","signature":"4"}]`)) + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestContentHashDoesNotEscapeHTML(t *testing.T) { + files := []HashFile{ + {Path: "a&b.png", Raw: []byte("xy")}, + {Path: "index.html", Raw: []byte("z")}, + } + got, err := ContentHash(files) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := sha256Hex([]byte( + `[{"path":"a&b.png","signature":"2"},` + + `{"path":"index.html","signature":"` + sha256Hex([]byte("z")) + `"}]`)) + if got != want { + t.Errorf("HTML escaping leaked into the digest input: got %q, want %q", got, want) + } +} + +func TestContentHashSortsByUTF16CodeUnit(t *testing.T) { + // U+FF3A(BMP,UTF-16 = 0xFF3A)与 U+1D400(补充平面,代理对首码元 0xD835)。 + // UTF-8 字节序:U+FF3A < U+1D400;UTF-16 码元序相反。 + got := sortPathsUTF16([]string{"Z.png", "\U0001D400.png"}) + if got[0] != "\U0001D400.png" || got[1] != "Z.png" { + t.Errorf("got %q, want UTF-16 code-unit order", got) + } +} diff --git a/shortcuts/apps/deploy/deps.go b/shortcuts/apps/deploy/deps.go new file mode 100644 index 0000000000..3df15a69e3 --- /dev/null +++ b/shortcuts/apps/deploy/deps.go @@ -0,0 +1,356 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" +) + +// Bounds on the walk. A page that references a file which references another is +// a graph, not a list, and the zip is assembled in memory. Both values match +// the web client; exceeding either stops the publish rather than trimming the +// payload, because a trimmed payload is a different payload and the GUI would +// have no way to tell. +const ( + maxDepFiles = 200 + maxDepDepth = 16 + // maxSkipNotes caps the reported skips so a broken page cannot flood stderr. + maxSkipNotes = 100 +) + +// The advice attached to each kind of skip. Kept next to the reference rules +// they belong to so that a change to one cannot leave the other behind. +const ( + adviceCreateOrDropReference = "create the missing file(s), or remove the references to them" + adviceCheckPermissions = "check the permissions on those paths" + adviceFixFileOrUseDir = "fix the file so its own references can be followed, or publish the directory with --dir" + adviceRuntimeURL = "a URL built at run time cannot be followed; publish the whole directory with --dir if the page needs those files" +) + +// SkipKind classifies why a referenced file was not published. Callers group by +// kind to give one piece of advice per problem rather than repeating it. +type SkipKind int + +const ( + // SkipMissing: the referenced file is not on disk. + SkipMissing SkipKind = iota + // SkipUnreadable: it exists but cannot be read, or is not a plain file. + SkipUnreadable + // SkipUnparsed: it was published but could not be read for further + // references, so anything it in turn references is absent. + SkipUnparsed + // SkipDynamic: a reference exists but is computed at run time, so no + // implementation can know which file it names. + SkipDynamic + // SkipOutsideDir: a --dir payload references something outside the + // directory being published. + SkipOutsideDir +) + +// Skip is one reference that was found but not published, or one file that was +// published without being searched. +// +// Advice travels with the skip rather than being looked up from Kind by the +// caller. A lookup table let the two input modes drift: the same bad reference +// was explained one way under --file-path and another under --dir, and fixing +// one never touched the other. Three acceptance rounds died on that. +type Skip struct { + Ref string + From string + Why string + Advice string + Kind SkipKind +} + +func (s Skip) String() string { + if s.Ref == s.From { + return fmt.Sprintf("%s: %s", s.From, s.Why) + } + return fmt.Sprintf("%s (referenced by %s): %s", s.Ref, s.From, s.Why) +} + +// collector walks the reference graph rooted at one entry file, breadth first. +type collector struct { + fio fileio.FileIO + root string // directory holding the entry, as passed to FileIO + rootAbs string // same directory, absolute and symlink-resolved + entryRel string + visited map[string]bool + validated map[string]bool + cands []Candidate + skipped []Skip + via map[string]string +} + +type queueItem struct { + rel string + depth int +} + +func (c *collector) join(rel string) string { + return filepath.Join(c.root, filepath.FromSlash(rel)) +} + +func (c *collector) note(kind SkipKind, ref, from, why, advice string) { + if len(c.skipped) >= maxSkipNotes { + return + } + c.skipped = append(c.skipped, Skip{Ref: ref, From: from, Why: why, Advice: advice, Kind: kind}) +} + +// limitError stops the publish when the payload outgrows what a single-file +// publish is meant to carry. +func limitError(what string) error { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", what). + WithHint("publish the whole directory with --dir instead: it packs what is there and follows no references, so neither limit applies") +} + +// symlinkError stops the publish on a symbolic link anywhere in the payload. +// Following one would publish a file from outside the directory the caller +// named, and refusing is also what the web client does, so a payload that +// publishes here is a payload the GUI can reproduce. +func symlinkError(rel string) error { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "%s is a symbolic link; symbolic links cannot be published", rel). + // Not "use --dir": that walker skips symbolic links too, and skips them + // silently, so it turns this refusal into a page that publishes without + // the file and says nothing. + WithHint("replace the link with a copy of the file it points at") +} + +// ensurePathSafe rejects a symbolic link at any level of rel, the payload root +// included. Checking the parents matters as much as the leaf: a linked +// directory would otherwise smuggle in whatever it points at. +func (c *collector) ensurePathSafe(rel string) error { + segments := strings.Split(rel, "/") + for i := range segments { + partial := strings.Join(segments[:i+1], "/") + abs := c.join(partial) + if c.validated[abs] { + continue + } + //nolint:forbidigo // fileio exposes no Lstat, and Stat follows links, which is exactly what must be detected here; the path is inside the cwd-checked root. + info, err := os.Lstat(abs) + if err != nil { + // Absence is not a safety problem; the read below reports it. + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return nil + } + if info.Mode()&os.ModeSymlink != 0 { + return symlinkError(partial) + } + c.validated[abs] = true + } + return nil +} + +// read returns the bytes of one payload file. required marks the entry, whose +// absence stops the publish; a missing dependency is only reported. +func (c *collector) read(rel string, required bool) ([]byte, int64, bool, error) { + if err := c.ensurePathSafe(rel); err != nil { + return nil, 0, false, err + } + p := c.join(rel) + // Name the file that asked for this one. The caller wrote a reference, not + // a file list, so "assets/logo.png is missing" is only actionable once they + // know which page to look in. + from := c.viaOf(rel) + if from == "" { + from = rel + } + st, err := c.fio.Stat(p) + if err != nil { + if required { + return nil, 0, false, inputPathError("--file-path", p, err) + } + switch { + case errors.Is(err, fs.ErrNotExist): + c.note(SkipMissing, rel, from, "the file does not exist", adviceCreateOrDropReference) + case errors.Is(err, fs.ErrPermission): + c.note(SkipUnreadable, rel, from, "the file cannot be read: permission denied", adviceCheckPermissions) + default: + c.note(SkipUnreadable, rel, from, "the path cannot be read", adviceCheckPermissions) + } + return nil, 0, false, nil + } + if !st.Mode().IsRegular() { + if required { + return nil, 0, false, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "--file-path %q is not a regular file", p).WithParam("--file-path") + } + c.note(SkipUnreadable, rel, from, "the path is not a regular file", adviceCheckPermissions) + return nil, 0, false, nil + } + f, err := c.fio.Open(p) + if err != nil { + if required { + return nil, 0, false, inputPathError("--file-path", p, err) + } + c.note(SkipUnreadable, rel, from, "the file cannot be opened", adviceCheckPermissions) + return nil, 0, false, nil + } + defer f.Close() + raw, err := io.ReadAll(f) + if err != nil { + if required { + return nil, 0, false, errs.NewInternalError(errs.SubtypeFileIO, "read %q: %v", p, err).WithCause(err) + } + c.note(SkipUnreadable, rel, from, "the file could not be read to the end", adviceCheckPermissions) + return nil, 0, false, nil + } + return raw, int64(len(raw)), true, nil +} + +// collectReferences returns the references written inside one payload file. +// A file whose type carries no references is a leaf and is never read. +func collectReferences(rel string, raw []byte) ([]string, int, error) { + switch refExtension(rel) { + case "html", "htm": + return scanHTML(raw) + case "svg": + return scanSVG(raw) + case "css": + return scanCSS(raw) + case "js", "mjs": + return scanJS(raw) + case "json": + return scanJSON(raw) + default: + return nil, 0, nil + } +} + +// walk performs the breadth-first closure. It mirrors the web client's batching +// so that the file-count check fires on the same reference, and so a reference +// reachable two ways is queued and deduplicated the same way. +func (c *collector) walk() error { + c.visited = map[string]bool{} + queue := []queueItem{{rel: c.entryRel}} + + for len(queue) > 0 { + pending := queue + queue = nil + + batch := make([]queueItem, 0, len(pending)) + for _, item := range pending { + if c.visited[item.rel] { + continue + } + c.visited[item.rel] = true + batch = append(batch, item) + } + if len(c.visited) > maxDepFiles { + return limitError(fmt.Sprintf( + "the page's references reach %d files, past the %d-file limit for a single-file publish", + len(c.visited), maxDepFiles)) + } + if len(batch) == 0 { + continue + } + + for _, item := range batch { + isEntry := item.rel == c.entryRel + raw, size, ok, err := c.read(item.rel, isEntry) + if err != nil { + return err + } + if !ok { + continue + } + via := "" + if !isEntry { + via = c.viaOf(item.rel) + } + c.cands = append(c.cands, Candidate{ + RelPath: item.rel, AbsPath: c.join(item.rel), Size: size, Via: via, + }) + if !parseableExts[refExtension(item.rel)] { + continue + } + next, err := c.expand(item, raw) + if err != nil { + return err + } + queue = append(queue, next...) + } + } + return nil +} + +// expand reads one file's references and returns the queue items they produce. +func (c *collector) expand(item queueItem, raw []byte) ([]queueItem, error) { + refs, unsupported, err := collectReferences(item.rel, raw) + if err != nil { + var pe *parseError + if errors.As(err, &pe) { + // The file still ships; it is only left unexpanded, so anything it + // references is absent from the payload. Saying so beats letting + // the page arrive with pieces missing and no explanation. + c.note(SkipUnparsed, item.rel, item.rel, unparsedReason(pe.code), adviceFixFileOrUseDir) + return nil, nil + } + return nil, err + } + if unsupported > 0 { + c.note(SkipDynamic, item.rel, item.rel, fmt.Sprintf( + "%d reference(s) are computed at run time and cannot be followed", unsupported), + adviceRuntimeURL) + } + + var out []queueItem + for _, ref := range refs { + rel, skip, err := resolveReference(item.rel, ref) + if err != nil { + return nil, err + } + if skip || c.visited[rel] { + continue + } + if item.depth >= maxDepDepth { + return nil, limitError(fmt.Sprintf( + "references nest more than %d levels deep: %s (%d levels below the entry) references %q", + maxDepDepth, item.rel, item.depth, ref)) + } + c.recordVia(rel, item.rel) + out = append(out, queueItem{rel: rel, depth: item.depth + 1}) + } + return out, nil +} + +func unparsedReason(code string) string { + switch code { + case "unsupported_base": + return "it declares , so its own references were not followed" + case "invalid_json": + return "it is not valid JSON, so its own references were not followed" + case "invalid_javascript": + return "it could not be parsed as JavaScript, so its own references were not followed" + default: + return "it could not be parsed, so its own references were not followed" + } +} + +// viaOf and recordVia remember which file first pointed at each dependency, +// used to explain a collision the caller never wrote down. +func (c *collector) recordVia(rel, from string) { + if c.via == nil { + c.via = map[string]string{} + } + if _, seen := c.via[rel]; !seen { + c.via[rel] = from + } +} + +func (c *collector) viaOf(rel string) string { return c.via[rel] } diff --git a/shortcuts/apps/deploy/deps_test.go b/shortcuts/apps/deploy/deps_test.go new file mode 100644 index 0000000000..808a03a0e7 --- /dev/null +++ b/shortcuts/apps/deploy/deps_test.go @@ -0,0 +1,403 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" +) + +// permissiveFIO delegates to os without the cwd sandbox so the scanner can be +// driven with absolute t.TempDir paths. Production goes through the cwd-bounded +// LocalFileIO, which these tests deliberately do not exercise. +type permissiveFIO struct{} + +func (permissiveFIO) Open(name string) (fileio.File, error) { return os.Open(name) } +func (permissiveFIO) Stat(name string) (fileio.FileInfo, error) { return os.Stat(name) } +func (permissiveFIO) ResolvePath(p string) (string, error) { return p, nil } +func (permissiveFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { + panic("Save not used in deploy unit tests") +} + +// collectRels runs CollectFile on root/entry and returns the published paths +// plus the rendered skip notes. +func collectRels(t *testing.T, root, entry string) ([]string, []string) { + t.Helper() + cands, _, skipped, err := CollectFile(permissiveFIO{}, filepath.Join(root, entry)) + if err != nil { + t.Fatalf("CollectFile: %v", err) + } + if len(cands) == 0 || cands[0].RelPath != entry { + t.Fatalf("entry must be the first candidate, got %+v", cands) + } + rels := make([]string, 0, len(cands)) + for _, c := range cands { + rels = append(rels, c.RelPath) + } + sort.Strings(rels) + notes := make([]string, 0, len(skipped)) + for _, sk := range skipped { + notes = append(notes, sk.String()) + } + return rels, notes +} + +// collectErr runs CollectFile expecting the publish to stop. +func collectErr(t *testing.T, root, entry string) error { + t.Helper() + cands, _, _, err := CollectFile(permissiveFIO{}, filepath.Join(root, entry)) + if err == nil { + t.Fatalf("expected the publish to stop, got %d files", len(cands)) + } + return err +} + +func TestCollectFileFollowsDependencyClosure(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "page.html"), ` + + + + + + +
+ + +`) + mustWrite(t, filepath.Join(root, "style.css"), `@import "theme.css"; +.a { background: url('assets/bg.jpg'); }`) + mustWrite(t, filepath.Join(root, "theme.css"), `.b{}`) + mustWrite(t, filepath.Join(root, "app.js"), `document.title = "ok";`) + mustWrite(t, filepath.Join(root, "lib", "boot.js"), `window.booted = true;`) + for _, a := range []string{"favicon.png", "bg.jpg", "logo.png", "logo@2x.png", "inline.png"} { + mustWrite(t, filepath.Join(root, "assets", a), "img") + } + // Not referenced by anything: --file-path publishes the closure, not the + // directory, so this must stay out. + mustWrite(t, filepath.Join(root, "unrelated.html"), "") + + rels, skipped := collectRels(t, root, "page.html") + want := []string{ + "app.js", "assets/bg.jpg", "assets/favicon.png", "assets/inline.png", + "assets/logo.png", "assets/logo@2x.png", "lib/boot.js", + "page.html", "style.css", "theme.css", + } + if strings.Join(rels, ",") != strings.Join(want, ",") { + t.Fatalf("closure mismatch:\n got %v\nwant %v", rels, want) + } + if len(skipped) != 0 { + t.Fatalf("expected no skips, got %v", skipped) + } +} + +// A reference the browser resolves against the document rather than against the +// script -- fetch, Worker, XHR -- must resolve from the payload root even when +// the script sits in a subdirectory. Getting this backwards puts a file at the +// wrong path, which is a file set the GUI cannot reproduce. +func TestCollectFileResolvesRuntimeReferencesAgainstTheDocument(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "index.html"), ``) + mustWrite(t, filepath.Join(root, "js", "app.js"), ` +fetch('./data.json'); +window.fetch('./ignored.json'); +new Worker('./worker.js'); +new URL('./sibling.js', import.meta.url); +`) + mustWrite(t, filepath.Join(root, "data.json"), `{}`) + mustWrite(t, filepath.Join(root, "worker.js"), ``) + mustWrite(t, filepath.Join(root, "js", "sibling.js"), ``) + // Same names one directory down: picked up only if the rewrite were skipped. + mustWrite(t, filepath.Join(root, "js", "data.json"), `{}`) + mustWrite(t, filepath.Join(root, "ignored.json"), `{}`) + + rels, _ := collectRels(t, root, "index.html") + want := []string{"data.json", "index.html", "js/app.js", "js/sibling.js", "worker.js"} + if strings.Join(rels, ",") != strings.Join(want, ",") { + t.Fatalf("got %v, want %v", rels, want) + } +} + +func TestCollectFileIgnoresExternalAndInertReferences(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "page.html"), ` + + + + +nav +anchor + + +
+`) + for _, f := range []string{"other.html", "ghost.css", "submit.php", "canonical.html"} { + mustWrite(t, filepath.Join(root, f), "x") + } + + rels, skipped := collectRels(t, root, "page.html") + if strings.Join(rels, ",") != "page.html" { + t.Fatalf("only the entry should be published, got %v", rels) + } + if len(skipped) != 0 { + t.Fatalf("external and navigation references must not be reported as skips, got %v", skipped) + } +} + +func TestCollectFileStripsQueryAndFragment(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "page.html"), ` + + +`) + mustWrite(t, filepath.Join(root, "style.css"), ".a{}") + mustWrite(t, filepath.Join(root, "app.js"), "") + mustWrite(t, filepath.Join(root, "assets", "icon one.png"), "img") + + rels, skipped := collectRels(t, root, "page.html") + want := []string{"app.js", "assets/icon one.png", "page.html", "style.css"} + if strings.Join(rels, ",") != strings.Join(want, ",") { + t.Fatalf("got %v, want %v", rels, want) + } + if len(skipped) != 0 { + t.Fatalf("unexpected skips: %v", skipped) + } +} + +func TestCollectFileReportsMissingDependency(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "page.html"), ``) + mustWrite(t, filepath.Join(root, "style.css"), ".a{}") + + rels, skipped := collectRels(t, root, "page.html") + if strings.Join(rels, ",") != "page.html,style.css" { + t.Fatalf("a missing dependency must not drop the rest: %v", rels) + } + if len(skipped) != 1 || !strings.Contains(skipped[0], "gone.png") || + !strings.Contains(skipped[0], "does not exist") { + t.Fatalf("skip note should name the file and the reason: %v", skipped) + } +} + +// The publish stops rather than shipping a payload the web client would have +// refused: a reference above the payload root cannot be expressed in the +// published layout at all. +func TestCollectFileRejectsReferenceAboveEntryDirectory(t *testing.T) { + root := t.TempDir() + site := filepath.Join(root, "site") + mustWrite(t, filepath.Join(site, "page.html"), ``) + mustWrite(t, filepath.Join(root, "shared", "theme.css"), ".a{}") + + ve := requireValidation(t, collectErr(t, site, "page.html"), errs.SubtypeFailedPrecondition) + if !strings.Contains(ve.Message, "above the entry file") { + t.Errorf("message should say the reference points above the payload: %v", ve.Message) + } + if ve.Hint != hintReferenceEscapes { + t.Errorf("the way out must be the one written for this cause, got %q", ve.Hint) + } +} + +func TestCollectFileRejectsDangerousReferences(t *testing.T) { + for name, ref := range map[string]string{ + "file scheme": "file:///etc/hosts", + "windows drive": `c:\secrets.css`, + "colon decoded": "a%3Ab.css", + "bad percent": "a%ZZ.css", + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "page.html"), + ``) + ve := requireValidation(t, collectErr(t, root, "page.html"), errs.SubtypeFailedPrecondition) + if ve.Hint != hintFixReferenceText { + t.Errorf("a malformed reference must be answered by fixing the text, got %q", ve.Hint) + } + }) + } +} + +// A root-absolute reference is read as site-root-absolute, which for a +// single-file publish means the entry's own directory. +func TestCollectFileResolvesRootAbsoluteAgainstEntryDirectory(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "page.html"), ``) + mustWrite(t, filepath.Join(root, "style.css"), ".a{}") + + rels, skipped := collectRels(t, root, "page.html") + if strings.Join(rels, ",") != "page.html,style.css" { + t.Fatalf("got %v", rels) + } + if len(skipped) != 0 { + t.Fatalf("unexpected skips: %v", skipped) + } +} + +// Any symbolic link stops the publish, not only one pointing out of the +// payload. Following a link publishes a file the caller did not name, and the +// web client refuses them outright -- a payload accepted here has to be one the +// GUI can reproduce. +func TestCollectFileRejectsSymlink(t *testing.T) { + for name, target := range map[string]string{ + "inside the payload": "real.css", + "outside the payload": "../outside.css", + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + site := filepath.Join(root, "site") + mustWrite(t, filepath.Join(site, "page.html"), ``) + mustWrite(t, filepath.Join(site, "real.css"), ".a{}") + mustWrite(t, filepath.Join(root, "outside.css"), ".b{}") + if err := os.Symlink(target, filepath.Join(site, "linked.css")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + ve := requireValidation(t, collectErr(t, site, "page.html"), errs.SubtypeFailedPrecondition) + if !strings.Contains(ve.Message, "symbolic link") { + t.Errorf("message should name the symlink: %v", ve.Message) + } + }) + } +} + +func TestCollectFileTerminatesOnReferenceCycle(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "page.html"), ``) + mustWrite(t, filepath.Join(root, "b.html"), ``) + mustWrite(t, filepath.Join(root, "c.css"), `@import "c.css";`) + + rels, _ := collectRels(t, root, "page.html") + if strings.Join(rels, ",") != "b.html,c.css,page.html" { + t.Fatalf("got %v", rels) + } +} + +func TestCollectFileStopsAtFileLimit(t *testing.T) { + root := t.TempDir() + var refs strings.Builder + for i := 0; i < maxDepFiles+10; i++ { + name := "a/f" + itoa(i) + ".css" + mustWrite(t, filepath.Join(root, filepath.FromSlash(name)), ".a{}") + refs.WriteString(``) + } + mustWrite(t, filepath.Join(root, "page.html"), refs.String()) + + ve := requireValidation(t, collectErr(t, root, "page.html"), errs.SubtypeFailedPrecondition) + // The count is named so the caller can see how far past the limit they are; + // the way out (--dir) rides on the hint, which the message does not carry. + if !strings.Contains(ve.Message, "200-file limit") || !strings.Contains(ve.Message, "reach 211 files") { + t.Errorf("hitting the cap should name the limit and the actual count: %v", ve.Message) + } + if !strings.Contains(ve.Hint, "--dir") { + t.Errorf("the way out belongs in the hint, got %q", ve.Hint) + } +} + +// The depth limit is checked the way the file limit is: the chain is built at +// run time rather than committed, because none of its files carries an +// assertion of its own -- seventeen stylesheets exist only to be seventeen +// levels. Both implementations read the limit from the same written contract, +// so pinning it against a fixture would cost twenty files to confirm a constant. +func TestCollectFileStopsAtDepthLimit(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "page.html"), ``) + for i := 0; i <= maxDepDepth+1; i++ { + mustWrite(t, filepath.Join(root, "c"+itoa(i)+".css"), `@import "c`+itoa(i+1)+`.css";`) + } + mustWrite(t, filepath.Join(root, "c"+itoa(maxDepDepth+2)+".css"), ".end{}") + + ve := requireValidation(t, collectErr(t, root, "page.html"), errs.SubtypeFailedPrecondition) + // The message names the file and the reference that overflowed, so the + // caller can cut the chain instead of guessing where it runs deep. + if !strings.Contains(ve.Message, "nest more than 16 levels deep") || + !strings.Contains(ve.Message, "references") { + t.Errorf("exceeding the depth limit should name the reference that did it: %v", ve.Message) + } +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + var b []byte + for i > 0 { + b = append([]byte{byte('0' + i%10)}, b...) + i /= 10 + } + return string(b) +} + +// A dry-run that returns a green light and a real publish that fails is worse +// than either alone, so the collision has to be detectable before any write. +func TestCollectFileRecordsWhoPulledEachDependencyIn(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "report.html"), ``) + mustWrite(t, filepath.Join(root, "index.html"), ``) + + cands, _, _, err := CollectFile(permissiveFIO{}, filepath.Join(root, "report.html")) + if err != nil { + t.Fatalf("CollectFile: %v", err) + } + if len(cands) != 2 || cands[0].Via != "" || cands[1].Via != "report.html" { + t.Fatalf("Via must name the referrer, got %+v", cands) + } + _, _, err = BuildManifest(cands, "report.html") + ve := requireValidation(t, err, errs.SubtypeFailedPrecondition) + if !strings.Contains(ve.Message, "report.html references it") { + t.Errorf("the conflict must name who pulled the other index.html in, got %q", ve.Message) + } +} + +func TestResolveReferenceClassification(t *testing.T) { + type want struct { + rel string + skip bool + err bool + } + cases := map[string]struct { + from, ref string + want want + }{ + "sibling": {"index.html", "style.css", want{rel: "style.css"}}, + "up one": {"a/b.html", "../c.css", want{rel: "c.css"}}, + "same dir": {"a/b.html", "c.css", want{rel: "a/c.css"}}, + "root absolute": {"index.html", "/deep/x.css", want{rel: "deep/x.css"}}, + "bare specifier": {"js/app.js", "lodash", want{rel: "js/lodash"}}, + "above root": {"a/b.html", "../../escape.css", want{err: true}}, + "backslash": {"index.html", `a\b.css`, want{err: true}}, + "file scheme": {"index.html", "file:///x", want{err: true}}, + "windows drive": {"index.html", `C:\x`, want{err: true}}, + "decoded colon": {"index.html", "a%3Ab.css", want{err: true}}, + "https": {"index.html", "https://x/y.css", want{skip: true}}, + "protocol rel": {"index.html", "//x/y.css", want{skip: true}}, + "data uri": {"index.html", "data:text/css,a", want{skip: true}}, + "mailto": {"index.html", "mailto:a@b.c", want{skip: true}}, + "fragment": {"index.html", "#top", want{skip: true}}, + "query only": {"index.html", "?v=1", want{skip: true}}, + "blank": {"index.html", " ", want{skip: true}}, + "trailing slash": {"index.html", "assets/", want{rel: "assets"}}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + rel, skip, err := resolveReference(c.from, c.ref) + switch { + case c.want.err: + requireValidation(t, err, errs.SubtypeFailedPrecondition) + case c.want.skip: + if err != nil || !skip { + t.Fatalf("expected a skip, got rel=%q skip=%v err=%v", rel, skip, err) + } + default: + if err != nil || skip || rel != c.want.rel { + t.Fatalf("got rel=%q skip=%v err=%v, want %q", rel, skip, err, c.want.rel) + } + } + }) + } +} diff --git a/shortcuts/apps/deploy/diagnose.go b/shortcuts/apps/deploy/diagnose.go new file mode 100644 index 0000000000..75e5202a83 --- /dev/null +++ b/shortcuts/apps/deploy/diagnose.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" +) + +// DiagnoseDir reports references inside a directory payload that will not +// resolve once the payload is online. +// +// --dir publishes what is in the directory and follows nothing, which is the +// point: the caller chose the file set. But it also means a page referencing a +// stylesheet one level up publishes cleanly and then renders unstyled, with +// nothing said at any point. That silence is what makes the failure expensive +// -- and it is where every "use --dir instead" suggestion sends people, so the +// suggestion has to stop being a way to launder a broken payload into a +// successful publish. +// +// This only reports. The file set is untouched: a reference that does not +// resolve is the caller's to fix, and guessing which file they meant would be +// worse than saying what is missing. +func DiagnoseDir(fio fileio.FileIO, root string, candidates []Candidate) []Skip { + published := make(map[string]bool, len(candidates)) + for _, c := range candidates { + published[c.RelPath] = true + } + + var out []Skip + seen := map[string]bool{} + note := func(kind SkipKind, ref, from, why, advice string) { + key := from + "\x00" + ref + if seen[key] || len(out) >= maxSkipNotes { + return + } + seen[key] = true + out = append(out, Skip{Ref: ref, From: from, Why: why, Advice: advice, Kind: kind}) + } + + for _, c := range candidates { + if !parseableExts[refExtension(c.RelPath)] { + continue + } + raw, ok := readAll(fio, c.AbsPath) + if !ok { + continue + } + refs, _, err := collectReferences(c.RelPath, raw) + if err != nil { + // A file that cannot be parsed is reported by the publish itself; + // there is nothing further to say about references it may hold. + continue + } + for _, ref := range refs { + rel, skip, rerr := resolveReference(c.RelPath, ref) + switch { + case skip: + continue + case rerr != nil: + // Reuse what resolveReference already decided. Restating it + // here is how --dir came to tell people to move /etc/passwd + // into the directory they were about to publish. + why, advice := referenceProblem(rerr, ref, c.RelPath) + note(SkipOutsideDir, ref, c.RelPath, why, advice) + case !published[rel]: + why, advice := whyNotPublished(root, rel) + note(SkipMissing, ref, c.RelPath, why, advice) + } + } + } + return out +} + +// referenceProblem unpacks the reason and the way out that resolveReference +// attached to a rejected reference, so both input modes say the same thing +// about the same reference. +func referenceProblem(err error, ref, from string) (why, advice string) { + var ve *errs.ValidationError + if !errors.As(err, &ve) { + return err.Error(), "" + } + // The message names the reference and the file holding it, both of which + // the caller already prints. Strip that exact prefix rather than searching + // for a separator: the reason itself can contain one ("it uses the file: + // scheme"), and cutting at the wrong colon leaves the word "scheme". + prefix := fmt.Sprintf("invalid reference %q in %s: ", ref, from) + if strings.HasPrefix(ve.Message, prefix) { + return ve.Message[len(prefix):], ve.Hint + } + return ve.Message, ve.Hint +} + +// whyNotPublished separates "there is no such file" from "the file is there but +// the walker did not take it", and names the level that stopped it. Telling +// someone a file is missing when they can see it in the directory reads as a +// bug in the tool; telling them a leaf is missing when the symbolic link is two +// directories up sends them looking in the wrong place. +func whyNotPublished(root, rel string) (why, advice string) { + segments := strings.Split(rel, "/") + for i := range segments { + partial := strings.Join(segments[:i+1], "/") + //nolint:forbidigo // fileio exposes no Lstat, and the distinction being drawn here is exactly the one Stat erases by following the link. + info, err := os.Lstat(filepath.Join(root, filepath.FromSlash(partial))) + if err != nil { + return "the published directory has no " + rel, adviceCreateOrDropReference + } + if info.Mode()&os.ModeSymlink != 0 { + return rel + " is reached through the symbolic link " + partial + ", which is never published", + "replace " + partial + " with a copy of what it points at" + } + if i < len(segments)-1 && !info.IsDir() { + return rel + " is not reachable: " + partial + " is not a directory", adviceCreateOrDropReference + } + } + //nolint:forbidigo // same rationale as above. + if info, err := os.Lstat(filepath.Join(root, filepath.FromSlash(rel))); err == nil && !info.Mode().IsRegular() { + return rel + " is not a regular file, so it is not published", adviceCreateOrDropReference + } + return rel + " is present but was not published", adviceCreateOrDropReference +} + +func readAll(fio fileio.FileIO, path string) ([]byte, bool) { + f, err := fio.Open(path) + if err != nil { + return nil, false + } + defer f.Close() + raw, err := io.ReadAll(f) + if err != nil { + return nil, false + } + return raw, true +} diff --git a/shortcuts/apps/deploy/diagnose_test.go b/shortcuts/apps/deploy/diagnose_test.go new file mode 100644 index 0000000000..2b5839059c --- /dev/null +++ b/shortcuts/apps/deploy/diagnose_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "path/filepath" + "strings" + "testing" +) + +// --dir follows no references by design, so nothing else notices that a page +// points at a file outside the directory. Publishing that quietly is how a +// caller ends up with a live URL that renders unstyled and no reason why -- +// and it is where every "use --dir instead" suggestion sends them. +func TestDiagnoseDirReportsReferencesItCannotSatisfy(t *testing.T) { + root := t.TempDir() + site := filepath.Join(root, "site") + mustWrite(t, filepath.Join(site, "index.html"), ` + + + + +nav`) + mustWrite(t, filepath.Join(site, "local.css"), ".a{}") + mustWrite(t, filepath.Join(root, "shared", "theme.css"), ".b{}") + + cands, _, _, err := CollectDir(permissiveFIO{}, site) + if err != nil { + t.Fatalf("CollectDir: %v", err) + } + skips := DiagnoseDir(permissiveFIO{}, site, cands) + + var got []string + for _, s := range skips { + got = append(got, s.String()) + } + joined := strings.Join(got, "\n") + if len(skips) != 2 { + t.Fatalf("expected exactly the two unsatisfiable references, got:\n%s", joined) + } + if !strings.Contains(joined, "../shared/theme.css") || !strings.Contains(joined, "missing.png") { + t.Errorf("both the out-of-directory and the missing reference should be named:\n%s", joined) + } + // An external URL is meant to stay external, and a navigation link is not a + // subresource; reporting either would train the caller to ignore the list. + if strings.Contains(joined, "remote.png") || strings.Contains(joined, "gone.html") { + t.Errorf("external and navigation references must not be reported:\n%s", joined) + } +} + +// The same bad reference has to be explained the same way whichever flag the +// caller used. Three acceptance rounds were lost to this drifting: --file-path +// was corrected each time and --dir kept the wording from the round before, +// which is how it came to advise moving /etc/passwd into a directory about to +// be published. Both sides now read their verdict from resolveReference, and +// this pins that. +func TestDirAndFilePathExplainTheSameReferenceIdentically(t *testing.T) { + refs := map[string]string{ + "file scheme": "file:///etc/hosts", + "windows drive": `c:\boot.css`, + "escapes root": "../shared/theme.css", + "bad percent": "a%ZZb.css", + } + for name, ref := range refs { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + site := filepath.Join(root, "site") + mustWrite(t, filepath.Join(site, "index.html"), + ``) + mustWrite(t, filepath.Join(root, "shared", "theme.css"), ".a{}") + + // --file-path refuses outright. + _, _, _, ferr := CollectFile(permissiveFIO{}, filepath.Join(site, "index.html")) + if ferr == nil { + t.Fatalf("--file-path should refuse %q", ref) + } + wantWhy, wantAdvice := referenceProblem(ferr, ref, "index.html") + + // --dir publishes anyway, but has to say the same thing about it. + cands, _, _, err := CollectDir(permissiveFIO{}, site) + if err != nil { + t.Fatalf("CollectDir: %v", err) + } + skips := DiagnoseDir(permissiveFIO{}, site, cands) + if len(skips) != 1 { + t.Fatalf("expected one report for %q, got %+v", ref, skips) + } + if skips[0].Why != wantWhy { + t.Errorf("reason differs between modes\n --dir %q\n --file-path %q", skips[0].Why, wantWhy) + } + if skips[0].Advice != wantAdvice { + t.Errorf("advice differs between modes\n --dir %q\n --file-path %q", skips[0].Advice, wantAdvice) + } + // Never tell anyone to move a system path into what they publish. + if strings.Contains(skips[0].Advice, "move the file") && name != "escapes root" { + t.Errorf("a malformed reference must not be answered with move-the-file: %q", skips[0].Advice) + } + }) + } +} diff --git a/shortcuts/apps/deploy/entry.go b/shortcuts/apps/deploy/entry.go new file mode 100644 index 0000000000..9ddc84da92 --- /dev/null +++ b/shortcuts/apps/deploy/entry.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "path/filepath" + "strings" + "unicode" + + "github.com/larksuite/cli/errs" +) + +// IndexName is the fixed entry file name inside the published payload. Because +// the entry always ends up as index.html at the payload root, route generation +// keeps working off the existing index.html -> / mapping. +const IndexName = "index.html" + +// FallbackAppName is used when no meaningful name can be derived. +const FallbackAppName = "html-app" + +// ValidateEntryFileName checks --entry-file. It must name a file sitting +// directly under --dir, so path separators are rejected outright. This +// deliberately does not go through SafeInputPath: that resolves relative to the +// cwd, which conflicts with "directly under --dir". The caller joins the +// validated name onto the already-resolved directory path. +func ValidateEntryFileName(name string) error { + if strings.TrimSpace(name) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--entry-file must not be empty").WithParam("--entry-file") + } + if strings.IndexFunc(name, unicode.IsControl) >= 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--entry-file must not contain control characters").WithParam("--entry-file") + } + if strings.ContainsAny(name, `/\`) { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "--entry-file %q must be a file name directly under --dir, not a path", name). + WithParam("--entry-file"). + WithHint("subdirectory entries are not supported; point --dir at the subdirectory instead") + } + if !strings.EqualFold(filepath.Ext(name), ".html") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--entry-file %q must be an .html file", name).WithParam("--entry-file") + } + return nil +} + +// ResolveEntry picks the entry file among the names sitting at the payload +// root, covering the four --entry-file / index.html combinations. +func ResolveEntry(entryFlag string, rootNames []string) (string, error) { + hasIndex, hasEntry := false, false + for _, n := range rootNames { + if n == IndexName { + hasIndex = true + } + if entryFlag != "" && n == entryFlag { + hasEntry = true + } + } + if entryFlag == "" { + if !hasIndex { + return "", errs.NewValidationError(errs.SubtypeFailedPrecondition, + "no entry file: --dir has no %s at its root", IndexName). + WithParam("--entry-file"). + WithHint("name the entry index.html, or pass --entry-file ") + } + return IndexName, nil + } + if !hasEntry { + return "", errs.NewValidationError(errs.SubtypeFailedPrecondition, + "--entry-file %q not found directly under --dir", entryFlag).WithParam("--entry-file") + } + if hasIndex { + return "", errs.NewValidationError(errs.SubtypeFailedPrecondition, + "entry conflict: --entry-file %q and the existing %s would both become the published entry", + entryFlag, IndexName). + WithParam("--entry-file"). + WithHint("drop --entry-file to publish the existing index.html, or move index.html out of --dir") + } + return entryFlag, nil +} + +// DeriveAppName derives the name used when auto-creating an app: the entry file +// name without its extension, falling back to the parent directory when the +// entry is index (which carries no information). +func DeriveAppName(absEntry string) string { + base := strings.TrimSuffix(filepath.Base(absEntry), filepath.Ext(absEntry)) + if base != "" && !strings.EqualFold(base, "index") { + return base + } + parent := filepath.Base(filepath.Dir(absEntry)) + if parent != "" && parent != "." && parent != string(filepath.Separator) { + return parent + } + return FallbackAppName +} diff --git a/shortcuts/apps/deploy/entry_test.go b/shortcuts/apps/deploy/entry_test.go new file mode 100644 index 0000000000..4b7c7ca1b1 --- /dev/null +++ b/shortcuts/apps/deploy/entry_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestResolveEntryCombinations(t *testing.T) { + cases := []struct { + name string + entryFlag string + rootNames []string + want string + wantErr string + }{ + {"未给且有 index.html", "", []string{"index.html", "a.css"}, "index.html", ""}, + {"未给且无 index.html", "", []string{"a.html"}, "", "no entry file"}, + {"给了且无 index.html", "page.html", []string{"page.html"}, "page.html", ""}, + {"给了且有 index.html", "page.html", []string{"index.html", "page.html"}, "", "entry conflict"}, + {"给了但不在目录下", "gone.html", []string{"other.html"}, "", "not found"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ResolveEntry(tc.entryFlag, tc.rootNames) + if tc.wantErr != "" { + // The command layer renders these from the typed fields, so a + // message that still reads right while the envelope regressed + // is a break the caller sees and this test would not. + ve := requireValidation(t, err, errs.SubtypeFailedPrecondition) + if !strings.Contains(ve.Message, tc.wantErr) { + t.Errorf("got message %q, want containing %q", ve.Message, tc.wantErr) + } + if ve.Param != "--entry-file" { + t.Errorf("Param = %q, want --entry-file", ve.Param) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestValidateEntryFileName(t *testing.T) { + bad := []string{"sub/page.html", `sub\page.html`, "notes.txt", "", "p\x00.html"} + for _, in := range bad { + ve := requireValidation(t, ValidateEntryFileName(in), errs.SubtypeInvalidArgument, errs.SubtypeFailedPrecondition) + if ve != nil && ve.Param != "--entry-file" { + t.Errorf("ValidateEntryFileName(%q): Param = %q, want --entry-file", in, ve.Param) + } + } + if err := ValidateEntryFileName("page.html"); err != nil { + t.Errorf("ValidateEntryFileName(\"page.html\") = %v, want nil", err) + } +} + +func TestDeriveAppName(t *testing.T) { + cases := []struct{ absEntry, want string }{ + {"/tmp/work/report.html", "report"}, + {"/tmp/work/index.html", "work"}, + {"/index.html", "html-app"}, + {"/tmp/work/INDEX.HTML", "work"}, + } + for _, tc := range cases { + if got := DeriveAppName(tc.absEntry); got != tc.want { + t.Errorf("DeriveAppName(%q) = %q, want %q", tc.absEntry, got, tc.want) + } + } +} diff --git a/shortcuts/apps/deploy/errassert_test.go b/shortcuts/apps/deploy/errassert_test.go new file mode 100644 index 0000000000..70a9c1c537 --- /dev/null +++ b/shortcuts/apps/deploy/errassert_test.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" +) + +// requireValidation asserts that err is the typed validation error the command +// layer expects, and returns it so a test can go on to check the fields that +// matter to it. +// +// Matching on message text alone would keep passing if the error stopped being +// typed, or lost its subtype, or dropped the cause it wraps -- the envelope a +// caller parses would break while the test stayed green. +func requireValidation(t *testing.T, err error, allowed ...errs.Subtype) *errs.ValidationError { + t.Helper() + if err == nil { + t.Fatal("expected an error, got nil") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + for _, want := range allowed { + if ve.Subtype == want { + return ve + } + } + t.Errorf("Subtype = %q, want one of %v", ve.Subtype, allowed) + return ve +} + +// requireCause asserts that the error preserves the underlying failure, so the +// reason an I/O path gave is still reachable from the envelope. +func requireCause(t *testing.T, err error, target error) { + t.Helper() + if !errors.Is(err, target) { + t.Errorf("error should preserve its cause %v, got %v", target, err) + } +} diff --git a/shortcuts/apps/deploy/fileset_golden_test.go b/shortcuts/apps/deploy/fileset_golden_test.go new file mode 100644 index 0000000000..d1d3f6845d --- /dev/null +++ b/shortcuts/apps/deploy/fileset_golden_test.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" +) + +// rootedFIO serves one fixture directory without the cwd sandbox, so the +// fixtures can live under testdata instead of forcing the test to chdir. +type rootedFIO struct{} + +func (rootedFIO) Open(name string) (fileio.File, error) { return os.Open(name) } +func (rootedFIO) Stat(name string) (fileio.FileInfo, error) { return os.Stat(name) } +func (rootedFIO) ResolvePath(p string) (string, error) { return p, nil } +func (rootedFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { + panic("Save not used in deploy unit tests") +} + +// goldenErrorMarkers ties each refusal the web client can raise to the wording +// this implementation uses for the same rule, so a fixture cannot pass by +// failing for an unrelated reason. +var goldenErrorMarkers = map[string]string{ + "invalid_reference": "invalid reference", + "entry_path_conflict": "entry conflict", + "symbolic_link": "symbolic link", + "max_depth_exceeded": "levels deep", + "file_count_exceeded": "file limit", +} + +// goldenFileSet is what the web client produced for one fixture directory. +type goldenFileSet struct { + Entry string `json:"entry"` + Files []string `json:"files"` + Error string `json:"error"` +} + +// TestFileSetMatchesWebClientGolden pins the dependency closure against the set +// the web client's own collector builds from the same directory. +// +// The fingerprint sent with a publish describes this set, and the GUI compares +// it against the set it would have collected itself. A file one side includes +// and the other does not produces no error anywhere -- it produces a page the +// GUI reports as permanently out of sync. Re-deriving the rules in Go and +// testing them against Go would not catch that, so the expectations here come +// from running the other implementation. +func TestFileSetMatchesWebClientGolden(t *testing.T) { + raw, err := os.ReadFile("testdata/fileset_golden.json") + if err != nil { + t.Fatalf("read golden: %v", err) + } + var cases map[string]goldenFileSet + if err := json.Unmarshal(raw, &cases); err != nil { + t.Fatalf("parse golden: %v", err) + } + if len(cases) == 0 { + t.Fatal("golden file has no cases") + } + + for name, want := range cases { + t.Run(name, func(t *testing.T) { + entry := want.Entry + if entry == "" { + entry = IndexName + } + cands, _, _, err := CollectFile(rootedFIO{}, filepath.Join("testdata/fixtures", name, entry)) + // The golden lists published paths, where the entry is index.html, + // so the rename has to run before comparing -- and it is where an + // entry collision surfaces. + var entries []PackEntry + if err == nil { + entries, _, err = BuildManifest(cands, entry) + } + + if want.Error != "" { + if err == nil { + t.Fatalf("expected the publish to stop (%s), got file set %v", want.Error, relsOf(cands)) + } + // Any error would satisfy "it failed", including one from an + // unrelated guard, which would leave the case green while the + // rule it exists for stopped working. Tie the failure to the + // one the other implementation raised. + ve := requireValidation(t, err, errs.SubtypeFailedPrecondition) + if marker, ok := goldenErrorMarkers[want.Error]; !ok { + t.Fatalf("golden names an error %q with no expected wording; add it", want.Error) + } else if !strings.Contains(ve.Message, marker) { + t.Errorf("stopped for the wrong reason\n got %q\nwant it to mention %q (%s)", ve.Message, marker, want.Error) + } + return + } + if err != nil { + t.Fatalf("expected a file set, got error: %v", err) + } + got := make([]string, 0, len(entries)) + for _, e := range entries { + got = append(got, strings.TrimPrefix(e.ZipPath, "output/")) + } + sort.Strings(got) + expected := append([]string(nil), want.Files...) + sort.Strings(expected) + if strings.Join(got, "\n") != strings.Join(expected, "\n") { + t.Errorf("file set differs from the web client\n got %v\nwant %v", got, expected) + } + }) + } +} + +func relsOf(cands []Candidate) []string { + out := make([]string, 0, len(cands)) + for _, c := range cands { + out = append(out, c.RelPath) + } + return out +} diff --git a/shortcuts/apps/deploy/guard.go b/shortcuts/apps/deploy/guard.go new file mode 100644 index 0000000000..68d1021636 --- /dev/null +++ b/shortcuts/apps/deploy/guard.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" +) + +// Limits caps the payload. These values are deliberately independent of the +// +html-publish limits (10 MiB per file / 20 MiB packed) and must not be +// merged with them in later refactors. +type Limits struct { + SingleHTMLBytes int64 + RawTotalBytes int64 + ZipBytes int64 +} + +// DefaultLimits returns the bare-HTML publish caps: 20 MiB per .html file, +// 200 MiB of raw bytes before packing and 50 MiB for the packed zip. The raw +// cap exists because the zip is assembled fully in memory — without it a huge +// directory exhausts memory before the packed-size check can fire. +func DefaultLimits() Limits { + return Limits{ + SingleHTMLBytes: 20 * 1024 * 1024, + RawTotalBytes: 200 * 1024 * 1024, + ZipBytes: 50 * 1024 * 1024, + } +} + +// sensitiveExactNames are file names that must never reach a published payload. +var sensitiveExactNames = map[string]bool{ + ".npmrc": true, ".netrc": true, ".pypirc": true, ".git-credentials": true, + "id_rsa": true, "id_dsa": true, "id_ecdsa": true, "id_ed25519": true, + "credentials": true, "service-account.json": true, +} + +// parentAnchoredCredentials are credential files whose own name is too generic +// to match on its own — .docker/config.json and .kube/config would otherwise +// sail through a base-name check as "config.json" and "config". The key is the +// conventional parent directory, the value the file name inside it. +var parentAnchoredCredentials = map[string]map[string]bool{ + ".docker": {"config.json": true}, + ".kube": {"config": true}, +} + +// secretOnlyDirs exist solely to hold secrets, so anything directly inside +// them is treated as a credential regardless of its name. +var secretOnlyDirs = map[string]bool{".ssh": true, ".gnupg": true, ".aws": true} + +// isSensitiveRel reports whether a "/"-delimited relative path holds a +// credential. It checks the leaf name and, because some credential files carry +// generic names, also the parent directory each segment sits in. +func isSensitiveRel(rel string) bool { + parts := strings.Split(rel, "/") + if isSensitiveName(parts[len(parts)-1]) { + return true + } + for i := 1; i < len(parts); i++ { + parent := strings.ToLower(parts[i-1]) + name := strings.ToLower(parts[i]) + if secretOnlyDirs[parent] { + return true + } + if names, ok := parentAnchoredCredentials[parent]; ok && names[name] { + return true + } + } + return false +} + +// isSensitiveName reports whether a base file name looks like a credential +// file. The .env family is prefix-matched so .env.local and .env.production +// are caught too — note this also catches a file literally named .env.html, +// which is why the single-file path runs this scan as well. +func isSensitiveName(name string) bool { + // Matching is case-insensitive throughout: macOS and Windows file systems + // are case-insensitive, so a file named .ENV or ID_RSA is the same file to + // the user and must not slip past the scan. + lower := strings.ToLower(name) + if lower == ".env" || strings.HasPrefix(lower, ".env.") { + return true + } + if strings.HasSuffix(lower, ".pem") || strings.HasSuffix(lower, ".p12") || + strings.HasSuffix(lower, ".pfx") || strings.HasSuffix(lower, ".keystore") { + return true + } + return sensitiveExactNames[lower] +} + +const maxListedInError = 10 + +func joinTruncated(items []string, max int) string { + if len(items) <= max { + return strings.Join(items, ", ") + } + return strings.Join(items[:max], ", ") + ", ..." +} + +// HumanBytes renders a byte count the way the flag help and the docs write +// limits, so an operator can line the two up without doing arithmetic. +func HumanBytes(n int64) string { + const mib = 1024 * 1024 + if n >= mib { + return fmt.Sprintf("%.1f MiB", float64(n)/float64(mib)) + } + if n >= 1024 { + return fmt.Sprintf("%.1f KiB", float64(n)/1024) + } + return fmt.Sprintf("%d B", n) +} + +// Guard runs the credential scan and the two pre-pack size caps. It returns the +// waived credential files when allowSensitive is set so callers can surface +// them. Callers must run this in Validate, not DryRun, so that --dry-run also +// exits non-zero on a hit. +func Guard(candidates []Candidate, allowSensitive bool, lim Limits) ([]string, error) { + var sensitive []string + for _, c := range candidates { + if isSensitiveRel(c.RelPath) { + sensitive = append(sensitive, c.RelPath) + } + } + if len(sensitive) > 0 && !allowSensitive { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "the payload contains %d credential file(s) that should not be published: %s", + len(sensitive), joinTruncated(sensitive, maxListedInError)). + WithHint("remove them from the payload, or pass --allow-sensitive if shipping them is intentional") + } + + var oversize []string + var total int64 + for _, c := range candidates { + total += c.Size + if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > lim.SingleHTMLBytes { + oversize = append(oversize, fmt.Sprintf("%s (%s)", c.RelPath, HumanBytes(c.Size))) + } + } + if len(oversize) > 0 { + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "%d HTML file(s) exceed the %s per-file limit: %s", + len(oversize), HumanBytes(lim.SingleHTMLBytes), joinTruncated(oversize, maxListedInError)). + WithHint("split or trim the oversized page(s); the cap applies to each single .html file") + } + if total > lim.RawTotalBytes { + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "payload total size %s exceeds the %s limit before packing", + HumanBytes(total), HumanBytes(lim.RawTotalBytes)). + WithHint("narrow --dir to the directory that actually holds the site, or drop large assets from it") + } + return sensitive, nil +} diff --git a/shortcuts/apps/deploy/guard_test.go b/shortcuts/apps/deploy/guard_test.go new file mode 100644 index 0000000000..40f3cebcb7 --- /dev/null +++ b/shortcuts/apps/deploy/guard_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "strings" + "testing" +) + +func TestSensitiveNameCoverage(t *testing.T) { + // 本包自有的凭证名单契约。+html-publish 长期要下线, + // 这里不跟随它的后续变更。 + hit := []string{ + ".env", ".env.local", ".env.production", ".env.html", + ".npmrc", "id_rsa", ".git-credentials", + // 大小写不敏感:macOS/Windows 文件系统同名同文件,不能绕过扫描。 + ".ENV", ".Env.Local", "ID_RSA", "key.PEM", "Credentials", + } + for _, n := range hit { + if !isSensitiveName(n) { + t.Errorf("isSensitiveName(%q) = false, want true", n) + } + } + miss := []string{"index.html", "environment.html", "readme.md", "env.js"} + for _, n := range miss { + if isSensitiveName(n) { + t.Errorf("isSensitiveName(%q) = true, want false", n) + } + } +} + +func TestGuardRejectsSensitiveUnlessWaived(t *testing.T) { + cands := []Candidate{{RelPath: "index.html", Size: 10}, {RelPath: ".env", Size: 5}} + if _, err := Guard(cands, false, DefaultLimits()); err == nil || + !strings.Contains(err.Error(), "credential file") { + t.Fatalf("got %v, want a credential-file rejection", err) + } + waived, err := Guard(cands, true, DefaultLimits()) + if err != nil { + t.Fatalf("--allow-sensitive should waive the scan: %v", err) + } + if len(waived) != 1 || waived[0] != ".env" { + t.Errorf("waived = %v, want [.env]", waived) + } +} + +func TestGuardRejectsOversize(t *testing.T) { + lim := Limits{SingleHTMLBytes: 20, RawTotalBytes: 100, ZipBytes: 50} + if _, err := Guard([]Candidate{{RelPath: "a.html", Size: 25}}, false, lim); err == nil || + !strings.Contains(err.Error(), "per-file limit") { + t.Fatalf("got %v, want a per-file size rejection", err) + } + big := []Candidate{{RelPath: "a.html", Size: 10}, {RelPath: "b.png", Size: 200}} + if _, err := Guard(big, false, lim); err == nil || + !strings.Contains(err.Error(), "total size") { + t.Fatalf("got %v, want a total-size rejection", err) + } +} + +func TestDefaultLimits(t *testing.T) { + lim := DefaultLimits() + if lim.SingleHTMLBytes != 20*1024*1024 { + t.Errorf("SingleHTMLBytes = %d, want 20 MiB", lim.SingleHTMLBytes) + } + if lim.ZipBytes != 50*1024*1024 { + t.Errorf("ZipBytes = %d, want 50 MiB", lim.ZipBytes) + } + if lim.RawTotalBytes != 200*1024*1024 { + t.Errorf("RawTotalBytes = %d, want 200 MiB", lim.RawTotalBytes) + } +} + +func TestIsSensitiveRelCoversParentAnchoredPairs(t *testing.T) { + // 这些文件的 basename 太通用(config.json / config),只看叶子名必然漏过, + // 必须按父目录锚定。前两项含 registry auth token 与集群证书。 + hit := []string{ + ".docker/config.json", ".kube/config", ".aws/credentials", ".aws/config", + "nested/.docker/config.json", ".DOCKER/CONFIG.JSON", + ".ssh/known_hosts", ".ssh/id_rsa", ".gnupg/secring.gpg", + // .aws 整目录纳管:sso 缓存的 token 藏在多层子目录里, + // 只锚定 .aws/credentials 会漏掉 .aws/sso/cache/*.json。 + ".aws/sso/cache/abc.json", ".aws/cli/cache/x.json", + "assets/.env", + } + for _, rel := range hit { + if !isSensitiveRel(rel) { + t.Errorf("isSensitiveRel(%q) = false, want true", rel) + } + } + miss := []string{ + "config.json", "config", "assets/config.json", + "docker/config.json", "index.html", "docs/kube/config.md", + } + for _, rel := range miss { + if isSensitiveRel(rel) { + t.Errorf("isSensitiveRel(%q) = true, want false", rel) + } + } +} + +func TestGuardBlocksParentAnchoredCredentials(t *testing.T) { + cands := []Candidate{ + {RelPath: "index.html", Size: 10}, + {RelPath: ".docker/config.json", Size: 5}, + } + if _, err := Guard(cands, false, DefaultLimits()); err == nil || + !strings.Contains(err.Error(), ".docker/config.json") { + t.Fatalf("got %v, want the payload rejected naming .docker/config.json", err) + } +} diff --git a/shortcuts/apps/deploy/manifest.go b/shortcuts/apps/deploy/manifest.go new file mode 100644 index 0000000000..1b25e06bd1 --- /dev/null +++ b/shortcuts/apps/deploy/manifest.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" +) + +// PackEntry is one file of the upload payload. ZipPath is the protocol layout +// inside the zip; Content carries CLI-generated files such as routes.json. +type PackEntry struct { + ZipPath string + AbsPath string + Content []byte + Size int64 +} + +// BuildManifest turns collected candidates into the zip manifest: the entry's +// ZipPath becomes output/index.html (only inside the zip — the file on disk is +// untouched), everything else keeps its relative path. The returned html paths +// are post-rename and feed route generation. +func BuildManifest(candidates []Candidate, entryRel string) ([]PackEntry, []string, error) { + entries := make([]PackEntry, 0, len(candidates)+1) + htmlRels := make([]string, 0, len(candidates)) + seenEntry := false + seenPath := make(map[string]bool, len(candidates)) + for _, c := range candidates { + rel := c.RelPath + if rel == entryRel { + rel = IndexName + seenEntry = true + } + // The entry rename can collide: a payload whose entry is page.html but + // which also carries its own index.html would produce two files at the + // same zip path, and which one survives unpacking is undefined. + if seenPath[rel] { + if rel == IndexName && entryRel != IndexName { + // Name who dragged the other index.html in: under --file-path + // the caller never wrote it down, so "the payload already + // contains one" is not something they can act on by itself. + origin := "it is in the payload" + if c.Via != "" { + origin = fmt.Sprintf("%s references it", c.Via) + } + return nil, nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "entry conflict: %q is published as %s, but the payload has its own %s (%s)", + entryRel, IndexName, IndexName, origin). + WithHint("rename one of the two files, or publish the directory with --dir and pick the entry with --entry-file") + } + return nil, nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "the payload maps two files onto the same published path %q", rel) + } + seenPath[rel] = true + entries = append(entries, PackEntry{ + ZipPath: "output/" + rel, + AbsPath: c.AbsPath, + Size: c.Size, + }) + if strings.EqualFold(filepath.Ext(rel), ".html") { + htmlRels = append(htmlRels, rel) + } + } + if !seenEntry { + return nil, nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "entry file %q is missing from the collected payload", entryRel) + } + return entries, htmlRels, nil +} diff --git a/shortcuts/apps/deploy/manifest_test.go b/shortcuts/apps/deploy/manifest_test.go new file mode 100644 index 0000000000..bb4c2634dc --- /dev/null +++ b/shortcuts/apps/deploy/manifest_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestBuildManifestRenamesEntry(t *testing.T) { + cands := []Candidate{ + {RelPath: "page.html", AbsPath: "/site/page.html", Size: 3}, + {RelPath: "assets/x.css", AbsPath: "/site/assets/x.css", Size: 4}, + {RelPath: "other.html", AbsPath: "/site/other.html", Size: 2}, + } + entries, htmlRels, err := BuildManifest(cands, "page.html") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := map[string]string{} + for _, e := range entries { + got[e.ZipPath] = e.AbsPath + } + if got["output/index.html"] != "/site/page.html" { + t.Errorf("entry not renamed to output/index.html: %v", got) + } + if got["output/assets/x.css"] != "/site/assets/x.css" { + t.Errorf("non-entry file lost its relative path: %v", got) + } + if got["output/other.html"] != "/site/other.html" { + t.Errorf("non-entry html must still be published: %v", got) + } + htmlRels = sortPathsUTF16(htmlRels) + if len(htmlRels) != 2 || htmlRels[0] != "index.html" || htmlRels[1] != "other.html" { + t.Errorf("htmlRels = %v, want [index.html other.html]", htmlRels) + } +} + +func TestBuildManifestMissingEntry(t *testing.T) { + _, _, err := BuildManifest([]Candidate{{RelPath: "a.css"}}, "index.html") + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("got %v, want a missing-entry error", err) + } +} + +// A closure that pulls in a sibling index.html while the entry carries another +// name would put two files at output/index.html; which one survives unpacking +// is undefined, so the publish must stop instead. +func TestBuildManifestRejectsIndexCollision(t *testing.T) { + _, _, err := BuildManifest([]Candidate{ + {RelPath: "page.html"}, + {RelPath: "index.html"}, + }, "page.html") + ve := requireValidation(t, err, errs.SubtypeFailedPrecondition) + if !strings.Contains(ve.Message, "entry conflict") { + t.Errorf("message should name the conflict, got %q", ve.Message) + } +} diff --git a/shortcuts/apps/deploy/ref.go b/shortcuts/apps/deploy/ref.go new file mode 100644 index 0000000000..68f3f3d6b2 --- /dev/null +++ b/shortcuts/apps/deploy/ref.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "net/url" + "regexp" + "strings" + "unicode/utf8" + + "github.com/larksuite/cli/errs" +) + +// The reference grammar below mirrors the web client's collector exactly. The +// two implementations must produce the same file set from the same directory, +// because the publish carries a fingerprint of that set and the GUI compares it +// against its own. A file the two sides disagree about does not surface as an +// error anywhere -- it surfaces as a page the GUI reports as permanently out of +// sync, with nothing to explain why. + +// externalReferenceRe matches references that do not name a file in the +// payload: anything with a scheme, a protocol-relative URL, a bare fragment. +var externalReferenceRe = regexp.MustCompile(`(?i)^(?:[a-z][a-z\d+.\-]*:|//|#)`) + +// windowsDriveRe and fileSchemeRe are rejected outright rather than skipped: +// they name a location on the machine that a published page could never reach, +// so a payload containing one is malformed rather than merely incomplete. +var ( + windowsDriveRe = regexp.MustCompile(`(?i)^[a-z]:[\\/]`) + fileSchemeRe = regexp.MustCompile(`(?i)^file:`) +) + +// Two ways out, and they are not interchangeable. A reference that is simply +// malformed is fixed by editing it; one that climbs out of the payload needs +// the file moved *and* the reference rewritten, because "../" keeps climbing +// wherever the file ends up. +// +// Neither suggests --dir. The entry is published as index.html at the payload +// root, so a reference above it cannot be expressed in any mode: pointing --dir +// at the parent is rejected because the entry must sit at that directory's own +// root, and pointing it at the page's own directory publishes without the file +// and says so only as a warning. +const ( + hintFixReferenceText = "correct the reference where it is written: a published page reaches its files by a relative path inside the entry file's directory" + hintReferenceEscapes = "the entry file's directory becomes the site root, so no reference may climb above it — move the file into that directory and rewrite the reference to match (../shared/app.css -> shared/app.css); moving the file alone changes nothing, because ../ still climbs" +) + +// invalidReferenceError is the publish-stopping error for a reference the +// payload must not contain. hint says how to get out of this particular +// failure; a hint that does not fit its cause sends the caller round a loop. +func invalidReferenceError(ref, from, why, hint string) error { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "invalid reference %q in %s: %s", ref, from, why). + WithHint(hint) +} + +// resolveReference turns one raw reference written inside importerRel into a +// payload-relative path. It returns skip=true for references that name nothing +// local, and an error for references the payload must not contain at all. +// +// The order of the checks is load-bearing: the dangerous forms are rejected +// before the external-reference test, so `file:///etc/passwd` and `c:\secrets` +// fail rather than being quietly treated as external URLs. +func resolveReference(importerRel, ref string) (rel string, skip bool, err error) { + trimmed := strings.TrimSpace(ref) + // One leading and one trailing quote, matching the web client. Values + // arriving from a CSS or JS parser are already unquoted; this only catches + // the ones written with quotes inside an attribute. + trimmed = strings.TrimPrefix(trimmed, `"`) + trimmed = strings.TrimPrefix(trimmed, `'`) + trimmed = strings.TrimSuffix(trimmed, `"`) + trimmed = strings.TrimSuffix(trimmed, `'`) + + if trimmed == "" || strings.HasPrefix(trimmed, "?") { + return "", true, nil + } + switch { + case strings.Contains(trimmed, `\`): + return "", false, invalidReferenceError(ref, importerRel, "it contains a backslash", hintFixReferenceText) + case strings.ContainsRune(trimmed, 0): + return "", false, invalidReferenceError(ref, importerRel, "it contains a NUL byte", hintFixReferenceText) + case windowsDriveRe.MatchString(trimmed): + return "", false, invalidReferenceError(ref, importerRel, "it names an absolute Windows path", hintFixReferenceText) + case fileSchemeRe.MatchString(trimmed): + return "", false, invalidReferenceError(ref, importerRel, "it uses the file: scheme", hintFixReferenceText) + } + if externalReferenceRe.MatchString(trimmed) { + return "", true, nil + } + + pathOnly := trimmed + if i := strings.IndexAny(pathOnly, "?#"); i >= 0 { + pathOnly = pathOnly[:i] + } + decoded, derr := url.PathUnescape(pathOnly) + // decodeURIComponent rejects a malformed escape and a sequence that does + // not decode to valid UTF-8; PathUnescape only catches the first, so the + // second is checked here to keep the two implementations in step. + if derr != nil || !utf8.ValidString(decoded) { + return "", false, invalidReferenceError(ref, importerRel, "it is not a valid percent-encoded path", hintFixReferenceText) + } + pathOnly = decoded + switch { + case strings.Contains(pathOnly, `\`): + return "", false, invalidReferenceError(ref, importerRel, "it decodes to a path containing a backslash", hintFixReferenceText) + case strings.ContainsRune(pathOnly, 0): + return "", false, invalidReferenceError(ref, importerRel, "it decodes to a path containing a NUL byte", hintFixReferenceText) + case strings.Contains(pathOnly, ":"): + return "", false, invalidReferenceError(ref, importerRel, "it decodes to a path containing a colon", hintFixReferenceText) + } + + var segments []string + if strings.HasPrefix(pathOnly, "/") { + // Root-absolute: relative to the payload root, which for a single-file + // publish is the entry file's own directory. + segments = strings.Split(strings.TrimLeft(pathOnly, "/"), "/") + } else { + base := strings.Split(importerRel, "/") + segments = append(base[:len(base)-1:len(base)-1], strings.Split(pathOnly, "/")...) + } + + normalized := make([]string, 0, len(segments)) + for _, seg := range segments { + switch seg { + case "", ".": + continue + case "..": + if len(normalized) == 0 { + return "", false, invalidReferenceError(ref, importerRel, "it points above the entry file's directory", hintReferenceEscapes) + } + normalized = normalized[:len(normalized)-1] + default: + normalized = append(normalized, seg) + } + } + if len(normalized) == 0 { + return "", false, invalidReferenceError(ref, importerRel, "it does not name a file", hintFixReferenceText) + } + return strings.Join(normalized, "/"), false, nil +} + +// refExtension reads the extension the way both implementations do: the last +// dot in the last path segment, lowercased, empty when there is none. +func refExtension(rel string) string { + name := rel + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[i+1:] + } + i := strings.LastIndex(name, ".") + if i < 0 { + return "" + } + return strings.ToLower(name[i+1:]) +} + +// toDocumentRelative rewrites a reference that the browser would resolve +// against the document rather than against the file containing it -- fetch, +// Worker, XHR and service-worker URLs, and every path found inside JSON. The +// result is root-absolute, so it resolves from the payload root no matter which +// subdirectory the script or manifest sits in. +func toDocumentRelative(ref string) (string, bool) { + trimmed := strings.TrimSpace(ref) + if trimmed == "" || externalReferenceRe.MatchString(trimmed) { + return "", false + } + if strings.HasPrefix(trimmed, "/") { + return trimmed, true + } + return "/" + strings.TrimPrefix(trimmed, "./"), true +} + +// resourcePathRe is the set of extensions that make a string inside a JSON +// document look like a reference to a payload file. +var resourcePathRe = regexp.MustCompile( + `(?i)\.(?:aac|avif|css|csv|eot|gif|html?|ico|jpe?g|js|json|m4a|map|mjs|mp3|mp4|ogg|otf|png|svg|txt|ttf|wav|webm|webp|woff2?|xml|zip)(?:[?#]|$)`) + +// parseableExts are the file types that can reference other files. Anything +// else is a leaf: images, fonts, media. +var parseableExts = map[string]bool{ + "css": true, "htm": true, "html": true, "js": true, "json": true, "mjs": true, "svg": true, +} diff --git a/shortcuts/apps/deploy/ref_test.go b/shortcuts/apps/deploy/ref_test.go new file mode 100644 index 0000000000..1b22cf6555 --- /dev/null +++ b/shortcuts/apps/deploy/ref_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" +) + +// A hint that does not fit its cause sends the caller round a loop: they do what +// it says, hit the identical error, and conclude the tool is broken. This has +// happened twice on this path, so the pairing is pinned rather than left to +// review. +func TestInvalidReferenceHintMatchesItsCause(t *testing.T) { + cases := map[string]struct { + ref string + hint string + }{ + // Only a reference climbing out of the payload is fixed by moving a + // file, and even then the reference itself has to change with it. + "escapes the payload": {"../shared/app.css", hintReferenceEscapes}, + // The rest are malformed text: no amount of moving files helps. + "file scheme": {"file:///etc/hosts", hintFixReferenceText}, + "windows drive": {`c:\boot.css`, hintFixReferenceText}, + "backslash": {`sub\win.css`, hintFixReferenceText}, + "bad percent": {"a%ZZb.css", hintFixReferenceText}, + "decoded colon": {"a%3Ab.css", hintFixReferenceText}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + _, _, err := resolveReference("index.html", c.ref) + if err == nil { + t.Fatalf("expected %q to be rejected", c.ref) + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected a validation error, got %T", err) + } + if ve.Hint != c.hint { + t.Errorf("hint does not fit the cause\n got %q\nwant %q", ve.Hint, c.hint) + } + }) + } + if hintReferenceEscapes == hintFixReferenceText { + t.Fatal("the two hints must stay distinct; one advises moving files, the other editing text") + } +} diff --git a/shortcuts/apps/deploy/scan_css.go b/shortcuts/apps/deploy/scan_css.go new file mode 100644 index 0000000000..d8443d27ed --- /dev/null +++ b/shortcuts/apps/deploy/scan_css.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "regexp" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/tdewolff/parse/v2" + "github.com/tdewolff/parse/v2/css" +) + +// dataURIInSrcsetRe detects a data: URI anywhere in a srcset value. +var dataURIInSrcsetRe = regexp.MustCompile(`(?i)\bdata:`) + +// scanCSS collects url() targets and @import targets from a stylesheet. +// +// A tokenizer rather than a pattern: a url() written inside a comment or inside +// a string is not a reference, and treating it as one would put a file in the +// payload that the web client leaves out -- which is a fingerprint mismatch, not +// a harmless extra. +func scanCSS(raw []byte) ([]string, int, error) { + return scanCSSIgnoringErrors(raw, false), 0, nil +} + +func scanCSSIgnoringErrors(raw []byte, declarationList bool) []string { + lex := css.NewLexer(parse.NewInputBytes(raw)) + var refs []string + // pendingImport is set between an @import keyword and the end of its + // prelude; the first string or url in that window is the target. + pendingImport := false + + for { + tt, data := lex.Next() + switch tt { + case css.ErrorToken: + return refs + + case css.CommentToken, css.WhitespaceToken: + continue + + case css.AtKeywordToken: + pendingImport = !declarationList && + strings.EqualFold(strings.TrimPrefix(string(data), "@"), "import") + + case css.SemicolonToken, css.LeftBraceToken: + pendingImport = false + + case css.URLToken: + ref := unwrapCSSURL(string(data)) + if ref != "" { + refs = append(refs, ref) + } + pendingImport = false + + case css.StringToken: + if pendingImport { + if ref := decodeCSSEscapes(trimCSSQuotes(string(data))); ref != "" { + refs = append(refs, ref) + } + pendingImport = false + } + } + } +} + +// unwrapCSSURL turns the raw url(...) token into the path inside it. +func unwrapCSSURL(token string) string { + inner := token + if i := strings.Index(inner, "("); i >= 0 { + inner = inner[i+1:] + } + inner = strings.TrimSuffix(strings.TrimSpace(inner), ")") + return decodeCSSEscapes(trimCSSQuotes(strings.TrimSpace(inner))) +} + +// decodeCSSEscapes resolves the backslash escapes CSS uses to put otherwise +// illegal characters in a URL: a hex code point, or any single character taken +// literally. Without this a file named "a b.png", which has to be written +// url("a\ b.png"), reaches the resolver with its backslash intact and is +// rejected outright -- a valid stylesheet stops the publish. +func decodeCSSEscapes(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); { + if s[i] != '\\' || i+1 >= len(s) { + b.WriteByte(s[i]) + i++ + continue + } + i++ + // A backslash before a newline is a line continuation and contributes + // nothing; the newline goes with it. + if s[i] == '\n' { + i++ + continue + } + if !isHexDigit(s[i]) { + r, size := utf8.DecodeRuneInString(s[i:]) + b.WriteRune(r) + i += size + continue + } + // Up to six hex digits, ended by an optional single whitespace. + start := i + for i < len(s) && i-start < 6 && isHexDigit(s[i]) { + i++ + } + code, err := strconv.ParseUint(s[start:i], 16, 32) + if err != nil || code == 0 || code > unicode.MaxRune { + b.WriteRune(utf8.RuneError) + } else { + b.WriteRune(rune(code)) + } + if i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n') { + i++ + } + } + return b.String() +} + +func isHexDigit(c byte) bool { + return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' +} + +func trimCSSQuotes(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 && (s[0] == '"' || s[0] == '\'') && s[len(s)-1] == s[0] { + return s[1 : len(s)-1] + } + return s +} diff --git a/shortcuts/apps/deploy/scan_javascript.go b/shortcuts/apps/deploy/scan_javascript.go new file mode 100644 index 0000000000..620a21365a --- /dev/null +++ b/shortcuts/apps/deploy/scan_javascript.go @@ -0,0 +1,300 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "strings" + + "github.com/tdewolff/parse/v2" + "github.com/tdewolff/parse/v2/js" +) + +// documentResourceLoaders are helper names whose argument names a document +// resource. They come from the drawing/sketch libraries these pages are +// commonly generated with. +var documentResourceLoaders = map[string]bool{"loadJSON": true, "loadJson": true} + +// scanJS collects the files a script references: module specifiers, and the +// runtime calls whose argument is a literal URL. +// +// This needs a real parser rather than pattern matching. The rules turn on +// syntax, not on text: a bare `fetch(x)` counts while `window.fetch(x)` does +// not, `xhr.open` only counts when xhr was assigned `new XMLHttpRequest()`, and +// `new URL(x, import.meta.url)` resolves against the script while every other +// runtime reference resolves against the document. Matching text instead would +// build a different file set, and a different file set is a fingerprint the GUI +// can never reconcile. +// +// The returned count is of references that exist but could not be read -- +// a computed URL, a template with substitutions. They are reported, not +// followed; neither implementation can know what they resolve to. +func scanJS(src []byte) ([]string, int, error) { + ast, err := js.Parse(parse.NewInputBytes(src), js.Options{}) + if err != nil { + return nil, 0, &parseError{code: "invalid_javascript"} + } + s := &jsScan{xhrVars: map[string]bool{}} + // Two passes: the XMLHttpRequest variables have to be known before a call + // on one of them can be recognised, and a script may open the request + // above the line that declares it. + js.Walk(jsVisitor(s.collectXHRVars), ast) + js.Walk(jsVisitor(s.visit), ast) + return s.refs, s.unsupported, nil +} + +type jsScan struct { + xhrVars map[string]bool + refs []string + unsupported int +} + +// jsVisitor adapts a function to the walker's visitor interface. +type jsVisitor func(js.INode) + +func (v jsVisitor) Enter(n js.INode) js.IVisitor { v(n); return v } +func (v jsVisitor) Exit(js.INode) {} + +func (s *jsScan) collectXHRVars(n js.INode) { + decl, ok := n.(*js.VarDecl) + if !ok { + return + } + for _, item := range decl.List { + name, ok := item.Binding.(*js.Var) + if !ok || item.Default == nil { + continue + } + if newExpr, ok := item.Default.(*js.NewExpr); ok && identName(newExpr.X) == "XMLHttpRequest" { + s.xhrVars[string(name.Data)] = true + } + } +} + +func (s *jsScan) visit(n js.INode) { + switch v := n.(type) { + case *js.ImportStmt: + // A specifier is kept as written, package names included: resolving it + // is the path layer's job, and a name that happens to match a file in + // the payload is a file the page really does load. + if len(v.Module) > 0 { + s.refs = append(s.refs, unquoteJS(v.Module)) + } + case *js.ExportStmt: + if len(v.Module) > 0 { + s.refs = append(s.refs, unquoteJS(v.Module)) + } + case *js.CallExpr: + s.visitCall(v) + case *js.NewExpr: + s.visitNew(v) + } +} + +func (s *jsScan) visitCall(call *js.CallExpr) { + if isDynamicImport(call.X) { + s.addModuleSpecifier(argAt(&call.Args, 0)) + return + } + name := calleeName(call.X) + _, isBare := call.X.(*js.Var) + + isXHROpen := name == "open" && s.isXHRReceiver(call.X) + arg := argAt(&call.Args, 0) + if isXHROpen { + arg = argAt(&call.Args, 1) + } + + switch { + case isBare && name == "fetch", + isXHROpen, + isServiceWorkerRegister(call.X), + documentResourceLoaders[name]: + s.addDocumentRelative(arg) + } +} + +func (s *jsScan) visitNew(expr *js.NewExpr) { + switch identName(expr.X) { + case "Worker", "SharedWorker": + s.addDocumentRelative(argAt(expr.Args, 0)) + case "URL": + // new URL(path, import.meta.url) resolves against the script, so the + // specifier is kept as written rather than rewritten to the document. + if !isImportMetaURL(argAt(expr.Args, 1)) { + return + } + if str, ok := staticString(argAt(expr.Args, 0)); ok { + s.refs = append(s.refs, str) + } else { + s.unsupported++ + } + } +} + +// addDocumentRelative records a reference the browser resolves against the +// document rather than against the script holding it. +func (s *jsScan) addDocumentRelative(arg js.IExpr) { + if arg == nil { + return + } + // A URL built for the script's own location is handled where it is + // constructed; counting it here would report it twice. + if newExpr, ok := arg.(*js.NewExpr); ok && identName(newExpr.X) == "URL" && + isImportMetaURL(argAt(newExpr.Args, 1)) { + return + } + str, ok := staticString(arg) + if !ok { + s.unsupported++ + return + } + if rewritten, ok := toDocumentRelative(str); ok { + s.refs = append(s.refs, rewritten) + } +} + +// addModuleSpecifier records the target of a dynamic import(). Only a plain +// string literal counts: module specifiers are read by a lexer rather than by +// the expression analysis, and it does not evaluate template literals -- so +// import(`./x.js`) is an unresolved reference on both sides, and treating it as +// resolved here would put a file in the payload the web client leaves out. +func (s *jsScan) addModuleSpecifier(arg js.IExpr) { + if arg == nil { + return + } + if lit, ok := literalOf(arg); ok && lit.TokenType == js.StringToken { + s.refs = append(s.refs, unquoteJS(lit.Data)) + return + } + s.unsupported++ +} + +func (s *jsScan) isXHRReceiver(callee js.IExpr) bool { + dot, ok := callee.(*js.DotExpr) + if !ok { + return false + } + if recv, ok := dot.X.(*js.Var); ok && s.xhrVars[string(recv.Data)] { + return true + } + newExpr, ok := dot.X.(*js.NewExpr) + return ok && identName(newExpr.X) == "XMLHttpRequest" +} + +// ── expression helpers ─────────────────────────────────────────────────── + +func argAt(args *js.Args, i int) js.IExpr { + if args == nil || i >= len(args.List) { + return nil + } + return args.List[i].Value +} + +func identName(e js.IExpr) string { + if v, ok := e.(*js.Var); ok { + return string(v.Data) + } + return "" +} + +// literalOf normalises the two shapes a literal arrives in: the parser stores +// a member property by value and an argument by pointer. +func literalOf(e js.IExpr) (js.LiteralExpr, bool) { + switch v := e.(type) { + case js.LiteralExpr: + return v, true + case *js.LiteralExpr: + return *v, true + } + return js.LiteralExpr{}, false +} + +// propertyName reads the property of a member expression, which the parser +// stores as a literal holding the identifier text. +func propertyName(e js.IExpr) string { + if lit, ok := literalOf(e); ok { + return string(lit.Data) + } + return identName(e) +} + +// calleeName is the identifier for a bare call, or the property name for a +// member call -- including a computed one whose key is a literal string. +func calleeName(callee js.IExpr) string { + if v, ok := callee.(*js.Var); ok { + return string(v.Data) + } + if dot, ok := callee.(*js.DotExpr); ok { + return propertyName(dot.Y) + } + if idx, ok := callee.(*js.IndexExpr); ok { + if str, ok := staticString(idx.Y); ok { + return str + } + } + return "" +} + +// isServiceWorkerRegister matches `.serviceWorker.register`. +func isServiceWorkerRegister(callee js.IExpr) bool { + dot, ok := callee.(*js.DotExpr) + if !ok || propertyName(dot.Y) != "register" { + return false + } + inner, ok := dot.X.(*js.DotExpr) + return ok && propertyName(inner.Y) == "serviceWorker" +} + +func isImportMetaURL(e js.IExpr) bool { + dot, ok := e.(*js.DotExpr) + if !ok || propertyName(dot.Y) != "url" { + return false + } + _, ok = dot.X.(*js.ImportMetaExpr) + return ok +} + +// isDynamicImport matches the callee of import(...). The parser reports it as +// a literal holding the keyword rather than as an identifier, so testing for a +// variable named "import" never matches -- and code splitting, the single most +// common reason to write a dynamic import, would ship without its chunks. +func isDynamicImport(callee js.IExpr) bool { + if v, ok := callee.(*js.Var); ok { + return string(v.Data) == "import" + } + lit, ok := literalOf(callee) + return ok && string(lit.Data) == "import" +} + +// staticString reads a value the parser can resolve at rest: a string literal, +// or a template with no substitutions. Anything else is a runtime value. +func staticString(e js.IExpr) (string, bool) { + if lit, ok := literalOf(e); ok { + if lit.TokenType == js.StringToken { + return unquoteJS(lit.Data), true + } + return "", false + } + if tpl, ok := e.(*js.TemplateExpr); ok && tpl.Tag == nil && len(tpl.List) == 0 { + return trimTemplateTail(tpl.Tail), true + } + return "", false +} + +// unquoteJS strips the quotes a literal keeps in its raw form. Escape +// sequences are left alone: a path written with them is not one either +// implementation resolves to a different file. +func unquoteJS(raw []byte) string { + s := string(raw) + if len(s) >= 2 && (s[0] == '\'' || s[0] == '"') && s[len(s)-1] == s[0] { + return s[1 : len(s)-1] + } + return s +} + +func trimTemplateTail(raw []byte) string { + s := string(raw) + s = strings.TrimPrefix(s, "`") + return strings.TrimSuffix(s, "`") +} diff --git a/shortcuts/apps/deploy/scan_json.go b/shortcuts/apps/deploy/scan_json.go new file mode 100644 index 0000000000..dcf35c7a62 --- /dev/null +++ b/shortcuts/apps/deploy/scan_json.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "encoding/json" + "strings" +) + +// scanJSON collects the file paths written as values inside a JSON document -- +// a web manifest naming its icons, a sketch describing its assets. +// +// Only values are examined; a key that looks like a path is a label, not a +// reference. Paths found here resolve against the payload root rather than +// against the JSON file, because the code that reads them fetches them from the +// document's location. +func scanJSON(raw []byte) ([]string, int, error) { + var doc interface{} + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, 0, &parseError{code: "invalid_json"} + } + var refs []string + var walk func(interface{}) + walk = func(v interface{}) { + switch t := v.(type) { + case string: + if !resourcePathRe.MatchString(strings.TrimSpace(t)) { + return + } + if rewritten, ok := toDocumentRelative(t); ok { + refs = append(refs, rewritten) + } + case []interface{}: + for _, item := range t { + walk(item) + } + case map[string]interface{}: + for _, item := range t { + walk(item) + } + } + } + walk(doc) + return refs, 0, nil +} diff --git a/shortcuts/apps/deploy/scan_markup.go b/shortcuts/apps/deploy/scan_markup.go new file mode 100644 index 0000000000..d1d523af10 --- /dev/null +++ b/shortcuts/apps/deploy/scan_markup.go @@ -0,0 +1,315 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deploy + +import ( + "bytes" + "encoding/xml" + "errors" + "io" + "strings" + + "golang.org/x/net/html" +) + +// parseError marks a file that could not be understood. It does not stop the +// publish: the file still ships, it just is not searched for further +// references, exactly as the web client behaves. +type parseError struct{ code string } + +func (e *parseError) Error() string { return "parse failed: " + e.code } + +// markupRefAttrs lists the attributes that carry a subresource the document +// needs in order to render. Navigation attributes (a[href], form[action]) are +// deliberately absent: following them would pull an entire site in behind one +// page, which is what --dir is for. +// +// Matching is by local attribute name, so `xlink:href` and a plain `href` on +// and both hit the same entry. +var markupRefAttrs = map[string][]string{ + "audio": {"src"}, + "embed": {"src"}, + "iframe": {"src"}, + "img": {"src", "srcset"}, + "image": {"href"}, + "object": {"data"}, + "source": {"src", "srcset"}, + "track": {"src"}, + "use": {"href"}, + "video": {"src", "poster"}, +} + +// resourceLinkRels are the only relations whose href names a file the +// page needs. rel="canonical" and friends point at documents, not resources. +var resourceLinkRels = map[string]bool{ + "icon": true, "manifest": true, "modulepreload": true, "preload": true, "stylesheet": true, +} + +// inlineScriptTypes are the script types whose body is JavaScript. An empty +// type means JavaScript too; anything else (importmap, application/json, a +// template language) is data and must not be parsed as code. +var inlineScriptTypes = map[string]bool{ + "module": true, "text/javascript": true, "application/javascript": true, +} + +// scanHTML collects the references written in an HTML document. +func scanHTML(raw []byte) ([]string, int, error) { + doc, err := html.Parse(bytes.NewReader(raw)) + if err != nil { + // x/net/html implements the HTML5 recovery rules and does not reject + // documents, so this only fires on a read error. + return nil, 0, &parseError{code: "invalid_html"} + } + // A re-points every relative reference in the document at a + // location the payload has no way to model, so the file is left unexpanded + // rather than expanded against the wrong directory. + if findBaseHref(doc) { + return nil, 0, &parseError{code: "unsupported_base"} + } + var refs []string + unsupported := 0 + walkHTML(doc, func(n *html.Node) { + r, u := elementRefs(n.Data, htmlAttrs(n), textOf(n)) + refs = append(refs, r...) + unsupported += u + }) + return refs, unsupported, nil +} + +// scanSVG collects the references written in an SVG document. SVG is parsed as +// XML, so unlike HTML it can be rejected -- an undeclared namespace prefix or a +// stray tag makes the file unreadable to a browser too. +func scanSVG(raw []byte) ([]string, int, error) { + els, err := parseXMLElements(raw) + if err != nil { + return nil, 0, &parseError{code: "invalid_html"} + } + var refs []string + unsupported := 0 + for _, el := range els { + r, u := elementRefs(el.name, el.attrs, el.text) + refs = append(refs, r...) + unsupported += u + } + return refs, unsupported, nil +} + +// attrPair is one attribute reduced to the form both parsers agree on: the +// local name lowercased, and the raw value. +type attrPair struct{ name, value string } + +// elementRefs applies the subresource rules to one element. +func elementRefs(tag string, attrs []attrPair, text string) ([]string, int) { + tag = strings.ToLower(tag) + var refs []string + unsupported := 0 + + for _, want := range markupRefAttrs[tag] { + if v := attrValue(attrs, want); v != "" { + if want == "srcset" { + refs = append(refs, splitSrcset(v)...) + } else { + refs = append(refs, v) + } + } + } + if tag == "input" && strings.EqualFold(attrValue(attrs, "type"), "image") { + if v := attrValue(attrs, "src"); v != "" { + refs = append(refs, v) + } + } + if tag == "link" { + if v := attrValue(attrs, "href"); v != "" && hasResourceRel(attrValue(attrs, "rel")) { + refs = append(refs, v) + } + } + if tag == "script" { + if v := attrValue(attrs, "src"); v != "" { + refs = append(refs, v) + } else if inlineScriptTypes[strings.ToLower(strings.TrimSpace(attrValue(attrs, "type")))] || + strings.TrimSpace(attrValue(attrs, "type")) == "" { + r, u, err := scanJS([]byte(text)) + if err == nil { + refs = append(refs, r...) + unsupported += u + } + } + } + if tag == "style" { + refs = append(refs, scanCSSIgnoringErrors([]byte(text), false)...) + } + // A style attribute holds a declaration list rather than a full stylesheet. + if v := attrValue(attrs, "style"); v != "" { + refs = append(refs, scanCSSIgnoringErrors([]byte(v), true)...) + } + return refs, unsupported +} + +func attrValue(attrs []attrPair, name string) string { + for _, a := range attrs { + if a.name == name { + return a.value + } + } + return "" +} + +func hasResourceRel(rel string) bool { + for _, token := range strings.Fields(strings.ToLower(rel)) { + if resourceLinkRels[token] { + return true + } + } + return false +} + +// splitSrcset pulls the URLs out of a candidate list such as +// "a.png 1x, b@2x.png 2x". +// +// A list mentioning data: anywhere yields nothing at all, rather than the +// candidates around it. Splitting a data: URI on commas produces fragments that +// are not paths, and the web client takes the same all-or-nothing route, so a +// payload must not disagree about which images it contains. +func splitSrcset(v string) []string { + if dataURIInSrcsetRe.MatchString(v) { + return nil + } + var out []string + for _, part := range strings.Split(v, ",") { + part = strings.TrimSpace(part) + if i := strings.IndexAny(part, " \t\n\r\f"); i >= 0 { + part = part[:i] + } + if part != "" { + out = append(out, part) + } + } + return out +} + +// ── HTML tree helpers ──────────────────────────────────────────────────── + +func htmlAttrs(n *html.Node) []attrPair { + out := make([]attrPair, 0, len(n.Attr)) + for _, a := range n.Attr { + out = append(out, attrPair{name: strings.ToLower(a.Key), value: a.Val}) + } + return out +} + +// textOf returns the raw text directly inside an element, which is all that +//