mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
feat(apps): add TOS upload path for arch_type=4 html in +html-publish with arch_type-based routing
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
108
shortcuts/apps/html_publish_tos_client.go
Normal file
108
shortcuts/apps/html_publish_tos_client.go
Normal 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
|
||||
}
|
||||
197
shortcuts/apps/html_publish_tos_client_test.go
Normal file
197
shortcuts/apps/html_publish_tos_client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user