diff --git a/shortcuts/apps/apps_html_publish.go b/shortcuts/apps/apps_html_publish.go
index a3b873706..1d5d2209a 100644
--- a/shortcuts/apps/apps_html_publish.go
+++ b/shortcuts/apps/apps_html_publish.go
@@ -119,6 +119,27 @@ var AppsHTMLPublish = common.Shortcut{
AppID: strings.TrimSpace(rctx.Str("app-id")),
Path: strings.TrimSpace(rctx.Str("path")),
}
+
+ meta := queryAppMeta(ctx, rctx, spec.AppID)
+
+ // doubao-html (app_type=7, arch_type=4): zip + TOS path
+ if meta != nil && meta.AppType == 7 && meta.ArchType == 4 {
+ tosClient := appsHTMLPublishTOSAPI{runtime: rctx}
+ out, err := runHTMLPublishTOS(ctx, rctx.FileIO(), tosClient, rctx, spec)
+ if err != nil {
+ return err
+ }
+ rctx.OutFormat(out, nil, func(w io.Writer) {
+ if url, ok := out["online_url"].(string); ok && url != "" {
+ fmt.Fprintf(w, "url: %s\n", url)
+ } else if rid, ok := out["release_id"].(string); ok {
+ fmt.Fprintf(w, "release_id: %s (still building)\n", rid)
+ }
+ })
+ return nil
+ }
+
+ // Legacy html or meta query failed: existing multipart path
client := appsHTMLPublishAPI{runtime: rctx}
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
if err != nil {
@@ -256,3 +277,67 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
}
return out, nil
}
+
+func runHTMLPublishTOS(ctx context.Context, fio fileio.FileIO, tosClient appsHTMLPublishTOSClient, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
+ candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
+ if err != nil {
+ return nil, err
+ }
+ if err := ensureIndexHTML(candidates); err != nil {
+ return nil, err
+ }
+ if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
+ return nil, oversizeHTMLFilesError(hits)
+ }
+ var rawTotal int64
+ for _, c := range candidates {
+ rawTotal += c.Size
+ }
+ if rawTotal > maxHTMLPublishRawBytes {
+ return nil, appsValidationParamError("--path",
+ "--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes).
+ WithHint("reduce --path contents or choose a smaller subdirectory before packaging")
+ }
+
+ archive, err := buildHTMLPublishZip(fio, candidates)
+ if err != nil {
+ return nil, err
+ }
+ if archive.Size > maxHTMLPublishTarballBytes {
+ return nil, appsValidationParamError("--path",
+ "packed zip size %d bytes exceeds %d bytes limit", archive.Size, maxHTMLPublishTarballBytes).
+ WithHint("reduce --path contents, remove unrelated large files, then retry")
+ }
+
+ uploadURL, tosKey, err := tosClient.GetUploadURL(ctx, spec.AppID)
+ if err != nil {
+ return nil, err
+ }
+ if err := tosClient.UploadToTOS(ctx, uploadURL, archive); err != nil {
+ return nil, err
+ }
+ resp, err := tosClient.Deploy(ctx, spec.AppID, tosKey)
+ if err != nil {
+ return nil, err
+ }
+
+ out := map[string]interface{}{
+ "app_id": spec.AppID,
+ "status": resp.Status,
+ }
+ if resp.URL != "" {
+ out["online_url"] = resp.URL
+ }
+ if resp.Status == "building" && resp.ReleaseID != "" {
+ polled, pollErr := pollDeployStatus(ctx, rctx, spec.AppID, resp.ReleaseID)
+ if pollErr == nil && polled.Status == "finished" {
+ out["status"] = "finished"
+ out["online_url"] = polled.URL
+ return out, nil
+ }
+ out["release_id"] = resp.ReleaseID
+ out["hint"] = fmt.Sprintf("run `lark-cli apps +release-get --app-id %s --release-id %s` to check status",
+ spec.AppID, resp.ReleaseID)
+ }
+ return out, nil
+}
diff --git a/shortcuts/apps/html_publish_tos_client.go b/shortcuts/apps/html_publish_tos_client.go
new file mode 100644
index 000000000..09abb96aa
--- /dev/null
+++ b/shortcuts/apps/html_publish_tos_client.go
@@ -0,0 +1,108 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package apps
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+type deployResponse struct {
+ Status string
+ URL string
+ ReleaseID string
+}
+
+type appsHTMLPublishTOSClient interface {
+ GetUploadURL(ctx context.Context, appID string) (uploadURL string, tosKey string, err error)
+ UploadToTOS(ctx context.Context, uploadURL string, archive *htmlPublishArchive) error
+ Deploy(ctx context.Context, appID string, tosKey string) (*deployResponse, error)
+}
+
+type appsHTMLPublishTOSAPI struct {
+ runtime *common.RuntimeContext
+}
+
+func (api appsHTMLPublishTOSAPI) GetUploadURL(ctx context.Context, appID string) (string, string, error) {
+ path := fmt.Sprintf("%s/apps/%s/pre_release_html_code", apiBasePath, validate.EncodePathSegment(appID))
+ data, err := api.runtime.CallAPITyped("POST", path, nil, nil)
+ if err != nil {
+ return "", "", err
+ }
+ uploadURL, _ := data["upload_url"].(string)
+ tosKey, _ := data["tos_key"].(string)
+ if uploadURL == "" || tosKey == "" {
+ return "", "", appsSubprocessEnvelopeError("pre_release_html_code returned empty upload_url or tos_key")
+ }
+ if !strings.HasPrefix(uploadURL, "https://") {
+ return "", "", appsSubprocessEnvelopeError("upload_url must be https, got %q", uploadURL)
+ }
+ return uploadURL, tosKey, nil
+}
+
+func (api appsHTMLPublishTOSAPI) UploadToTOS(ctx context.Context, uploadURL string, archive *htmlPublishArchive) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(archive.Body))
+ if err != nil {
+ return appsFileIOError(err, "create TOS upload request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/zip")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return appsExternalToolError(err, "TOS upload failed: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return appsExternalToolError(nil, "TOS upload returned HTTP %d", resp.StatusCode)
+ }
+ return nil
+}
+
+func (api appsHTMLPublishTOSAPI) Deploy(ctx context.Context, appID string, tosKey string) (*deployResponse, error) {
+ path := fmt.Sprintf("%s/apps/%s/release_html_code", apiBasePath, validate.EncodePathSegment(appID))
+ body := map[string]interface{}{"tos_key": tosKey}
+ data, err := api.runtime.CallAPITyped("POST", path, nil, body)
+ if err != nil {
+ return nil, err
+ }
+ status, _ := data["status"].(string)
+ url, _ := data["online_url"].(string)
+ releaseID, _ := data["release_id"].(string)
+ return &deployResponse{Status: status, URL: url, ReleaseID: releaseID}, nil
+}
+
+var (
+ pollInterval = 10 * time.Second
+ pollTimeout = 60 * time.Second
+)
+
+func pollDeployStatus(ctx context.Context, rctx *common.RuntimeContext, appID, releaseID string) (*deployResponse, error) {
+ path := fmt.Sprintf("%s/apps/%s/releases/%s",
+ apiBasePath,
+ validate.EncodePathSegment(appID),
+ validate.EncodePathSegment(releaseID))
+ deadline := time.Now().Add(pollTimeout)
+ for time.Now().Before(deadline) {
+ data, err := rctx.CallAPITyped("GET", path, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ status, _ := data["status"].(string)
+ switch status {
+ case "finished":
+ url, _ := data["online_url"].(string)
+ return &deployResponse{Status: "finished", URL: url, ReleaseID: releaseID}, nil
+ case "failed":
+ return &deployResponse{Status: "failed", ReleaseID: releaseID}, nil
+ }
+ time.Sleep(pollInterval)
+ }
+ return &deployResponse{Status: "building", ReleaseID: releaseID}, nil
+}
diff --git a/shortcuts/apps/html_publish_tos_client_test.go b/shortcuts/apps/html_publish_tos_client_test.go
new file mode 100644
index 000000000..49493fc71
--- /dev/null
+++ b/shortcuts/apps/html_publish_tos_client_test.go
@@ -0,0 +1,197 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package apps
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+type fakeTOSClient struct {
+ uploadURL string
+ tosKey string
+ deployResp *deployResponse
+ uploadErr error
+ deployErr error
+ getURLErr error
+
+ // recorded calls for assertions
+ uploadedURL string
+ deployedKey string
+}
+
+func (f *fakeTOSClient) GetUploadURL(ctx context.Context, appID string) (string, string, error) {
+ if f.getURLErr != nil {
+ return "", "", f.getURLErr
+ }
+ return f.uploadURL, f.tosKey, nil
+}
+
+func (f *fakeTOSClient) UploadToTOS(ctx context.Context, uploadURL string, archive *htmlPublishArchive) error {
+ f.uploadedURL = uploadURL
+ return f.uploadErr
+}
+
+func (f *fakeTOSClient) Deploy(ctx context.Context, appID string, tosKey string) (*deployResponse, error) {
+ f.deployedKey = tosKey
+ if f.deployErr != nil {
+ return nil, f.deployErr
+ }
+ return f.deployResp, nil
+}
+
+func setupHTMLPublishTestDir(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil {
+ t.Fatalf("write fixture: %v", err)
+ }
+ return dir
+}
+
+func TestRunHTMLPublishTOS_SyncSuccess(t *testing.T) {
+ dir := setupHTMLPublishTestDir(t)
+ fio := newTestFIO()
+ client := &fakeTOSClient{
+ uploadURL: "https://tos.example.com/upload",
+ tosKey: "tos/key/123",
+ deployResp: &deployResponse{Status: "finished", URL: "https://app.example.com/app_x"},
+ }
+ spec := appsHTMLPublishSpec{AppID: "app_x", Path: dir}
+ out, err := runHTMLPublishTOS(context.Background(), fio, client, nil, spec)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if out["status"] != "finished" {
+ t.Errorf("status = %v, want finished", out["status"])
+ }
+ if out["online_url"] != "https://app.example.com/app_x" {
+ t.Errorf("online_url = %v", out["online_url"])
+ }
+ if out["app_id"] != "app_x" {
+ t.Errorf("app_id = %v, want app_x", out["app_id"])
+ }
+}
+
+func TestRunHTMLPublishTOS_GetUploadURLError(t *testing.T) {
+ dir := setupHTMLPublishTestDir(t)
+ fio := newTestFIO()
+ wantErr := errors.New("get upload url failed")
+ client := &fakeTOSClient{getURLErr: wantErr}
+ spec := appsHTMLPublishSpec{AppID: "app_x", Path: dir}
+ _, err := runHTMLPublishTOS(context.Background(), fio, client, nil, spec)
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("err = %v, want %v", err, wantErr)
+ }
+}
+
+func TestRunHTMLPublishTOS_UploadError(t *testing.T) {
+ dir := setupHTMLPublishTestDir(t)
+ fio := newTestFIO()
+ wantErr := errors.New("upload failed")
+ client := &fakeTOSClient{
+ uploadURL: "https://tos.example.com/upload",
+ tosKey: "tos/key/123",
+ uploadErr: wantErr,
+ }
+ spec := appsHTMLPublishSpec{AppID: "app_x", Path: dir}
+ _, err := runHTMLPublishTOS(context.Background(), fio, client, nil, spec)
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("err = %v, want %v", err, wantErr)
+ }
+}
+
+func TestRunHTMLPublishTOS_DeployError(t *testing.T) {
+ dir := setupHTMLPublishTestDir(t)
+ fio := newTestFIO()
+ wantErr := errors.New("deploy failed")
+ client := &fakeTOSClient{
+ uploadURL: "https://tos.example.com/upload",
+ tosKey: "tos/key/123",
+ deployErr: wantErr,
+ }
+ spec := appsHTMLPublishSpec{AppID: "app_x", Path: dir}
+ _, err := runHTMLPublishTOS(context.Background(), fio, client, nil, spec)
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("err = %v, want %v", err, wantErr)
+ }
+}
+
+func TestRunHTMLPublishTOS_MissingIndexHTML(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte(""), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ fio := newTestFIO()
+ client := &fakeTOSClient{
+ uploadURL: "https://tos.example.com/upload",
+ tosKey: "tos/key/123",
+ deployResp: &deployResponse{Status: "finished", URL: "https://app.example.com/app_x"},
+ }
+ spec := appsHTMLPublishSpec{AppID: "app_x", Path: dir}
+ _, err := runHTMLPublishTOS(context.Background(), fio, client, nil, spec)
+ if err == nil {
+ t.Fatalf("expected error for missing index.html")
+ }
+ problem := requireAppsValidationProblem(t, err)
+ if !strings.Contains(problem.Message, "index.html") {
+ t.Fatalf("message missing 'index.html': %v", problem.Message)
+ }
+}
+
+func TestRunHTMLPublishTOS_RejectsOversizeZip(t *testing.T) {
+ orig := maxHTMLPublishTarballBytes
+ maxHTMLPublishTarballBytes = 100
+ defer func() { maxHTMLPublishTarballBytes = orig }()
+
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "big.html"),
+ []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ fio := newTestFIO()
+ client := &fakeTOSClient{
+ uploadURL: "https://tos.example.com/upload",
+ tosKey: "tos/key/123",
+ deployResp: &deployResponse{Status: "finished"},
+ }
+ spec := appsHTMLPublishSpec{AppID: "app_x", Path: dir}
+ _, err := runHTMLPublishTOS(context.Background(), fio, client, nil, spec)
+ if err == nil {
+ t.Fatalf("expected oversize error")
+ }
+ problem := requireAppsValidationProblem(t, err)
+ if !strings.Contains(problem.Message, "exceeds") {
+ t.Fatalf("message missing 'exceeds': %v", problem.Message)
+ }
+}
+
+func TestRunHTMLPublishTOS_PassesURLAndKeyToClient(t *testing.T) {
+ dir := setupHTMLPublishTestDir(t)
+ fio := newTestFIO()
+ client := &fakeTOSClient{
+ uploadURL: "https://tos.example.com/upload/abc",
+ tosKey: "tos/key/456",
+ deployResp: &deployResponse{Status: "finished", URL: "https://app.example.com/app_x"},
+ }
+ spec := appsHTMLPublishSpec{AppID: "app_x", Path: dir}
+ _, err := runHTMLPublishTOS(context.Background(), fio, client, nil, spec)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if client.uploadedURL != "https://tos.example.com/upload/abc" {
+ t.Errorf("uploadedURL = %q, want https://tos.example.com/upload/abc", client.uploadedURL)
+ }
+ if client.deployedKey != "tos/key/456" {
+ t.Errorf("deployedKey = %q, want tos/key/456", client.deployedKey)
+ }
+}