From 13a24b7ba625f65478149d856ba9233f7202c529 Mon Sep 17 00:00:00 2001 From: anguohui Date: Mon, 6 Jul 2026 17:36:30 +0800 Subject: [PATCH] feat(apps): add TOS upload path for arch_type=4 html in +html-publish with arch_type-based routing --- shortcuts/apps/apps_html_publish.go | 85 ++++++++ shortcuts/apps/html_publish_tos_client.go | 108 ++++++++++ .../apps/html_publish_tos_client_test.go | 197 ++++++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 shortcuts/apps/html_publish_tos_client.go create mode 100644 shortcuts/apps/html_publish_tos_client_test.go 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) + } +}