feat(apps): add TOS upload path for arch_type=4 html in +html-publish with arch_type-based routing

This commit is contained in:
anguohui
2026-07-06 17:36:30 +08:00
parent b0862b25c8
commit 13a24b7ba6
3 changed files with 390 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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("<html></html>"), 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("<html></html>"), 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("<html></html>"), 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)
}
}