mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
Compare commits
5 Commits
docs-skill
...
feat/douba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13a24b7ba6 | ||
|
|
b0862b25c8 | ||
|
|
591fa610cd | ||
|
|
275e59a100 | ||
|
|
4fd026835c |
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -71,5 +72,8 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
|
||||
body["icon_url"] = icon
|
||||
}
|
||||
if agent := os.Getenv("LARKSUITE_CLI_AGENT"); agent != "" {
|
||||
body["app_source"] = agent
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -273,3 +273,92 @@ func TestAppsCreate_FullstackDryRun(t *testing.T) {
|
||||
t.Fatalf("dry-run should not contain message: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_WithAgentEnvVar(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT", "doubao")
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["app_source"] != "doubao" {
|
||||
t.Fatalf("body.app_source = %v, want doubao", sent["app_source"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_WithoutAgentEnvVar(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT", "")
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if _, present := sent["app_source"]; present {
|
||||
t.Fatalf("app_source should not be present when env var is empty: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if _, present := sent["app_source"]; present {
|
||||
t.Fatalf("app_source should not be present when env var is unset: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -35,6 +36,7 @@ const (
|
||||
const (
|
||||
scaffoldKindInit = "init"
|
||||
scaffoldKindUpgrade = "upgrade"
|
||||
scaffoldKindSkipped = "skipped"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -85,7 +87,7 @@ var AppsInit = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
template := resolveTemplate(rctx, appID)
|
||||
template := resolveTemplate(rctx, nil)
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Initialize app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
|
||||
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
|
||||
@@ -123,16 +125,20 @@ func defaultCloneDir(appID string) string {
|
||||
}
|
||||
|
||||
// resolveTemplate returns the scaffold template for an empty-repo `app init`.
|
||||
// An explicit --template wins. When omitted, it should be derived from the
|
||||
// app's tech stack.
|
||||
// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list`
|
||||
// or a get-app endpoint), read its tech stack, and map tech-stack -> template
|
||||
// through a (future) enum. Until that lands, fall back to defaultTemplate.
|
||||
func resolveTemplate(rctx *common.RuntimeContext, appID string) string {
|
||||
// An explicit --template wins. When meta is available the template is derived
|
||||
// from app_type/arch_type; otherwise it falls back to defaultTemplate.
|
||||
func resolveTemplate(rctx *common.RuntimeContext, meta *appMeta) string {
|
||||
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
|
||||
return t
|
||||
}
|
||||
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
|
||||
if meta != nil {
|
||||
switch {
|
||||
case meta.AppType == 7 && meta.ArchType == 3:
|
||||
return ""
|
||||
case meta.AppType == 7 && meta.ArchType == 4:
|
||||
return "vite-react"
|
||||
}
|
||||
}
|
||||
return defaultTemplate
|
||||
}
|
||||
|
||||
@@ -325,17 +331,18 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
|
||||
|
||||
// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir).
|
||||
// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch +
|
||||
// conditional `skills sync`. Returns "init" or "upgrade".
|
||||
func runScaffold(ctx context.Context, dir, appID, template string) (string, error) {
|
||||
// conditional `skills sync`. Returns "init", "upgrade", or "skipped".
|
||||
func runScaffold(ctx context.Context, dir, appID string, meta *appMeta, explicitTemplate string) (string, error) {
|
||||
if isStaticHtml(meta) {
|
||||
return scaffoldKindSkipped, nil
|
||||
}
|
||||
empty, err := isEmptyRepo(ctx, dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if empty {
|
||||
// isEmptyRepo treats a repo with no tracked files — or only the backend's
|
||||
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
|
||||
// appear, extend isEmptyRepo's allow-list accordingly.
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
|
||||
args := scaffoldInitArgs(meta, appID, explicitTemplate)
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
@@ -354,6 +361,24 @@ func runScaffold(ctx context.Context, dir, appID, template string) (string, erro
|
||||
return scaffoldKindUpgrade, nil
|
||||
}
|
||||
|
||||
// scaffoldInitArgs builds the npx argument list for `app init`. An explicit
|
||||
// template wins; otherwise, when meta is available, --app-type/--arch-type are
|
||||
// passed so miaoda-cli picks the right scaffold; nil meta falls back to
|
||||
// --template defaultTemplate.
|
||||
func scaffoldInitArgs(meta *appMeta, appID, explicitTemplate string) []string {
|
||||
base := []string{"-y", "--prefer-online", miaodaCLIPkg, "app", "init"}
|
||||
if explicitTemplate != "" {
|
||||
return append(base, "--template", explicitTemplate, "--app-id", appID)
|
||||
}
|
||||
if meta != nil {
|
||||
return append(base,
|
||||
"--app-type", strconv.Itoa(meta.AppType),
|
||||
"--arch-type", strconv.Itoa(meta.ArchType),
|
||||
"--app-id", appID)
|
||||
}
|
||||
return append(base, "--template", defaultTemplate, "--app-id", appID)
|
||||
}
|
||||
|
||||
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
|
||||
// matches the contract emitted by `apps +git-credential-init`.
|
||||
@@ -445,9 +470,12 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
meta := queryAppMeta(ctx, rctx, appID)
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
// Static HTML apps skip env-pull (they have no env vars).
|
||||
if isAlreadyInitialized(dir) {
|
||||
initLogf(rctx, "Already initialized at %s — refreshing local environment", dir)
|
||||
out := map[string]interface{}{
|
||||
@@ -457,6 +485,20 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
"committed": false,
|
||||
"pushed": false,
|
||||
}
|
||||
if meta != nil {
|
||||
out["app_type"] = meta.AppType
|
||||
out["arch_type"] = meta.ArchType
|
||||
}
|
||||
if isStaticHtml(meta) {
|
||||
out["env_pulled"] = false
|
||||
out["env_pull_skipped"] = true
|
||||
out["message"] = "Repository already initialized (static HTML app — no env pull needed)."
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Already initialized at %s\n", dir)
|
||||
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
@@ -487,9 +529,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return appsFailedPreconditionError("git executable not found on PATH").
|
||||
WithHint("install git and ensure it is on your PATH")
|
||||
}
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
return appsFailedPreconditionError("npx executable not found on PATH").
|
||||
WithHint("install Node.js (which provides npx) and ensure it is on your PATH")
|
||||
if !isStaticHtml(meta) {
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
return appsFailedPreconditionError("npx executable not found on PATH").
|
||||
WithHint("install Node.js (which provides npx) and ensure it is on your PATH")
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureEmptyDir(dir); err != nil {
|
||||
@@ -515,7 +559,8 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
|
||||
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
|
||||
explicitTemplate := strings.TrimSpace(rctx.Str("template"))
|
||||
scaffold, err := runScaffold(ctx, dir, appID, meta, explicitTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -530,15 +575,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
initLogf(rctx, "Working tree clean — skipped commit/push")
|
||||
}
|
||||
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled := envPullErr == ""
|
||||
if envPulled {
|
||||
initLogf(rctx, "Local environment written to %s", envFile)
|
||||
} else {
|
||||
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"repository_url": redactURLCredentials(repoURL),
|
||||
@@ -547,21 +583,43 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
"scaffold": scaffold,
|
||||
"committed": committed,
|
||||
"pushed": pushed,
|
||||
"env_pulled": envPulled,
|
||||
"message": "Repository initialized. You can start developing.",
|
||||
}
|
||||
if envPulled {
|
||||
out["env_file"] = envFile
|
||||
} else {
|
||||
out["env_pull_error"] = envPullErr
|
||||
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
|
||||
if meta != nil {
|
||||
out["app_type"] = meta.AppType
|
||||
out["arch_type"] = meta.ArchType
|
||||
}
|
||||
|
||||
var envFile string
|
||||
var envPulled bool
|
||||
if isStaticHtml(meta) {
|
||||
out["env_pulled"] = false
|
||||
out["env_pull_skipped"] = true
|
||||
} else {
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
var envPullErr string
|
||||
envFile, envPullErr = pullEnv(ctx, rctx, appID, dir)
|
||||
envPulled = envPullErr == ""
|
||||
out["env_pulled"] = envPulled
|
||||
if envPulled {
|
||||
initLogf(rctx, "Local environment written to %s", envFile)
|
||||
out["env_file"] = envFile
|
||||
} else {
|
||||
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
|
||||
out["env_pull_error"] = envPullErr
|
||||
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
|
||||
}
|
||||
}
|
||||
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
|
||||
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)
|
||||
if envPulled {
|
||||
if isStaticHtml(meta) {
|
||||
fmt.Fprintln(w, " (static HTML app — env pull skipped)")
|
||||
} else if envPulled {
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
|
||||
} else {
|
||||
envPullErr := out["env_pull_error"]
|
||||
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
|
||||
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
|
||||
}
|
||||
|
||||
@@ -48,14 +48,14 @@ func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeC
|
||||
}
|
||||
|
||||
func TestResolveTemplate(t *testing.T) {
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" {
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), nil); got != "foo" {
|
||||
t.Errorf("explicit --template = %q, want foo", got)
|
||||
}
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); got != defaultTemplate {
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), nil); got != defaultTemplate {
|
||||
t.Errorf("omitted --template = %q, want fallback %q", got, defaultTemplate)
|
||||
}
|
||||
// Whitespace-only --template is treated as omitted -> fallback.
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), "app_x"); got != defaultTemplate {
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), nil); got != defaultTemplate {
|
||||
t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate)
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func TestRunScaffold_EmptyRepo(t *testing.T) {
|
||||
t.Run("ls="+ls, func(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ls}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", nil, "nestjs-react-fullstack")
|
||||
if err != nil || kind != "init" {
|
||||
t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err)
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
|
||||
dir := t.TempDir() // no steering dir, no meta.json
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack")
|
||||
kind, err := runScaffold(context.Background(), dir, "app_x", nil, "nestjs-react-fullstack")
|
||||
if err != nil || kind != "upgrade" {
|
||||
t.Fatalf("kind=%q err=%v, want upgrade", kind, err)
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) {
|
||||
os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755)
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
|
||||
withFakeRunner(t, f)
|
||||
if _, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack"); err != nil {
|
||||
if _, err := runScaffold(context.Background(), dir, "app_x", nil, "nestjs-react-fullstack"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
|
||||
@@ -313,7 +313,7 @@ func TestRunScaffold_AppInitFailure(t *testing.T) {
|
||||
"npx -y": {stderr: "boom", err: errors.New("exit 1")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack"); err == nil {
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", nil, "nestjs-react-fullstack"); err == nil {
|
||||
t.Error("app init failure must propagate")
|
||||
}
|
||||
}
|
||||
@@ -1250,7 +1250,7 @@ func TestRunScaffold_NonEmpty_SyncFailure(t *testing.T) {
|
||||
"git ls-files": {stdout: "src/x.ts\n"},
|
||||
"npx -y": {err: errors.New("sync boom")},
|
||||
}})
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "tpl"); err == nil {
|
||||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", nil, "tpl"); err == nil {
|
||||
t.Error("npx app sync failure must surface as an error")
|
||||
}
|
||||
}
|
||||
@@ -1630,7 +1630,7 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||||
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||||
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", nil, "nestjs-react-fullstack")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from failing git subprocess")
|
||||
}
|
||||
@@ -1645,3 +1645,84 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_StaticHtmlSkipped(t *testing.T) {
|
||||
f := &fakeCommandRunner{}
|
||||
withFakeRunner(t, f)
|
||||
meta := &appMeta{AppType: 7, ArchType: 3}
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", meta, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindSkipped {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindSkipped)
|
||||
}
|
||||
if len(f.calls) != 0 {
|
||||
t.Errorf("expected no calls for static html, got %d: %v", len(f.calls), f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_DoubaoHtmlPassesTypes(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
meta := &appMeta{AppType: 7, ArchType: 4}
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", meta, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "7", "--arch-type", "4") {
|
||||
t.Errorf("expected --app-type 7 --arch-type 4 in args: %v", c)
|
||||
}
|
||||
if containsAll(c, "--template") {
|
||||
t.Errorf("should not contain --template when meta is present: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_NilMetaFallback(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--template", defaultTemplate) {
|
||||
t.Errorf("expected --template %s in args: %v", defaultTemplate, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_ExplicitTemplateOverride(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
|
||||
withFakeRunner(t, f)
|
||||
meta := &appMeta{AppType: 7, ArchType: 4}
|
||||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", meta, "custom-template")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if kind != scaffoldKindInit {
|
||||
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--template", "custom-template") {
|
||||
t.Errorf("expected --template custom-template in args: %v", c)
|
||||
}
|
||||
if containsAll(c, "--app-type") {
|
||||
t.Errorf("--template should override --app-type: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
65
shortcuts/apps/apps_meta.go
Normal file
65
shortcuts/apps/apps_meta.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// appMeta holds the numeric app_type and arch_type returned by the server.
|
||||
type appMeta struct {
|
||||
AppType int
|
||||
ArchType int
|
||||
}
|
||||
|
||||
// queryAppMeta fetches the app's metadata (numeric app_type and arch_type)
|
||||
// from the server. Returns nil when the API is unavailable or returns an
|
||||
// error — callers fall back to legacy behavior.
|
||||
func queryAppMeta(ctx context.Context, rctx *common.RuntimeContext, appID string) *appMeta {
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
app, _ := data["app"].(map[string]interface{})
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
appType, ok1 := toInt(app["app_type"])
|
||||
archType, ok2 := toInt(app["arch_type"])
|
||||
if !ok1 || !ok2 {
|
||||
return nil
|
||||
}
|
||||
return &appMeta{AppType: appType, ArchType: archType}
|
||||
}
|
||||
|
||||
// toInt converts a JSON number (float64 or json.Number) to int.
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case json.Number:
|
||||
i, err := strconv.Atoi(n.String())
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// isStaticHtml reports whether the app is a legacy static-HTML app
|
||||
// (app_type=7, arch_type=3) that has no template and no env vars.
|
||||
func isStaticHtml(meta *appMeta) bool {
|
||||
return meta != nil && meta.AppType == 7 && meta.ArchType == 3
|
||||
}
|
||||
160
shortcuts/apps/apps_meta_test.go
Normal file
160
shortcuts/apps/apps_meta_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// newMetaTestRuntime creates a RuntimeContext wired to an httpmock.Registry
|
||||
// so queryAppMeta can be tested without a real server.
|
||||
func newMetaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_meta_test"}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rt := common.TestNewRuntimeContextForAPI(
|
||||
context.Background(),
|
||||
&cobra.Command{Use: "+meta-test"},
|
||||
cfg, f, core.AsUser,
|
||||
)
|
||||
return rt, reg
|
||||
}
|
||||
|
||||
func TestQueryAppMeta_Success(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_test123",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_type": float64(7),
|
||||
"arch_type": float64(4),
|
||||
"name": "Test App",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
meta := queryAppMeta(context.Background(), rt, "app_test123")
|
||||
if meta == nil {
|
||||
t.Fatal("expected non-nil appMeta")
|
||||
}
|
||||
if meta.AppType != 7 {
|
||||
t.Errorf("AppType = %d, want 7", meta.AppType)
|
||||
}
|
||||
if meta.ArchType != 4 {
|
||||
t.Errorf("ArchType = %d, want 4", meta.ArchType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppMeta_APIError(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_err",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(500100),
|
||||
"msg": "internal server error",
|
||||
},
|
||||
})
|
||||
|
||||
meta := queryAppMeta(context.Background(), rt, "app_err")
|
||||
if meta != nil {
|
||||
t.Fatalf("expected nil for API error, got %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppMeta_MissingFields(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_partial",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"name": "Partial App",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
meta := queryAppMeta(context.Background(), rt, "app_partial")
|
||||
if meta != nil {
|
||||
t.Fatalf("expected nil when app_type/arch_type are missing, got %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppMeta_NoAppObject(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_noobj",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
meta := queryAppMeta(context.Background(), rt, "app_noobj")
|
||||
if meta != nil {
|
||||
t.Fatalf("expected nil when app object is absent, got %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStaticHtml(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
meta *appMeta
|
||||
want bool
|
||||
}{
|
||||
{"nil meta", nil, false},
|
||||
{"static html (7,3)", &appMeta{AppType: 7, ArchType: 3}, true},
|
||||
{"full stack (7,4)", &appMeta{AppType: 7, ArchType: 4}, false},
|
||||
{"other type (3,0)", &appMeta{AppType: 3, ArchType: 0}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isStaticHtml(tt.meta); got != tt.want {
|
||||
t.Errorf("isStaticHtml(%+v) = %v, want %v", tt.meta, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
wantN int
|
||||
wantOK bool
|
||||
}{
|
||||
{"float64", float64(42), 42, true},
|
||||
{"int", 7, 7, true},
|
||||
{"json.Number", json.Number("99"), 99, true},
|
||||
{"json.Number non-int", json.Number("abc"), 0, false},
|
||||
{"string", "7", 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
{"bool", true, 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
n, ok := toInt(tt.input)
|
||||
if n != tt.wantN || ok != tt.wantOK {
|
||||
t.Errorf("toInt(%v) = (%d, %v), want (%d, %v)", tt.input, n, ok, tt.wantN, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
71
shortcuts/apps/html_publish_zip.go
Normal file
71
shortcuts/apps/html_publish_zip.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// htmlPublishArchive is the in-memory zip ready for TOS upload.
|
||||
type htmlPublishArchive struct {
|
||||
Body []byte
|
||||
Size int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
func buildHTMLPublishZip(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishArchive, error) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, appsValidationParamError("--path", "no files to pack")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
hasher := sha256.New()
|
||||
multi := io.MultiWriter(&buf, hasher)
|
||||
zw := zip.NewWriter(multi)
|
||||
|
||||
for _, c := range candidates {
|
||||
if err := writeHTMLPublishZipEntry(fio, zw, c); err != nil {
|
||||
_ = zw.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, appsFileIOError(err, "zip close: %v", err)
|
||||
}
|
||||
|
||||
return &htmlPublishArchive{
|
||||
Body: buf.Bytes(),
|
||||
Size: int64(buf.Len()),
|
||||
SHA256: hex.EncodeToString(hasher.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writeHTMLPublishZipEntry(fio fileio.FileIO, zw *zip.Writer, c htmlPublishCandidate) error {
|
||||
if isUnsafeRelPath(c.RelPath) {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "invalid zip entry name %q", c.RelPath)
|
||||
}
|
||||
|
||||
src, err := fio.Open(c.AbsPath)
|
||||
if err != nil {
|
||||
return appsInputPathEntryError(c.AbsPath, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
w, err := zw.Create(c.RelPath)
|
||||
if err != nil {
|
||||
return appsFileIOError(err, "create zip entry %s: %v", c.RelPath, err)
|
||||
}
|
||||
if _, err := io.Copy(w, src); err != nil {
|
||||
return appsFileIOError(err, "copy %s: %v", c.RelPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
175
shortcuts/apps/html_publish_zip_test.go
Normal file
175
shortcuts/apps/html_publish_zip_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildHTMLPublishZip_RoundTrip(t *testing.T) {
|
||||
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, "style.css"), []byte("body{}"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fio := newTestFIO()
|
||||
candidates, err := walkHTMLPublishCandidates(fio, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
archive, err := buildHTMLPublishZip(fio, candidates)
|
||||
if err != nil {
|
||||
t.Fatalf("buildHTMLPublishZip: %v", err)
|
||||
}
|
||||
|
||||
if len(archive.SHA256) != 64 {
|
||||
t.Fatalf("SHA256 wrong len: %d", len(archive.SHA256))
|
||||
}
|
||||
if archive.Size <= 0 || int64(len(archive.Body)) != archive.Size {
|
||||
t.Fatalf("size=%d body=%d", archive.Size, len(archive.Body))
|
||||
}
|
||||
|
||||
r, err := zip.NewReader(bytes.NewReader(archive.Body), archive.Size)
|
||||
if err != nil {
|
||||
t.Fatalf("read zip: %v", err)
|
||||
}
|
||||
names := make(map[string]bool)
|
||||
for _, f := range r.File {
|
||||
names[f.Name] = true
|
||||
}
|
||||
if !names["index.html"] || !names["style.css"] {
|
||||
t.Errorf("zip entries = %v, want index.html and style.css", names)
|
||||
}
|
||||
|
||||
// Verify content round-trip for index.html.
|
||||
for _, f := range r.File {
|
||||
if f.Name == "index.html" {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open entry: %v", err)
|
||||
}
|
||||
body, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil || string(body) != "<html></html>" {
|
||||
t.Fatalf("body=%q err=%v", body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishZip_EmptyCandidates(t *testing.T) {
|
||||
if _, err := buildHTMLPublishZip(newTestFIO(), nil); err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishZip_SHA256Stable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<h1>hi</h1>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidates := []htmlPublishCandidate{
|
||||
{RelPath: "index.html", AbsPath: filepath.Join(dir, "index.html"), Size: 11},
|
||||
}
|
||||
fio := newTestFIO()
|
||||
a1, err := buildHTMLPublishZip(fio, candidates)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a2, err := buildHTMLPublishZip(fio, candidates)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a1.SHA256 != a2.SHA256 {
|
||||
t.Errorf("SHA256 not stable: %s vs %s", a1.SHA256, a2.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHTMLPublishZipEntry_OpenFailure(t *testing.T) {
|
||||
zw := zip.NewWriter(io.Discard)
|
||||
defer zw.Close()
|
||||
err := writeHTMLPublishZipEntry(newTestFIO(), zw, htmlPublishCandidate{
|
||||
RelPath: "x.html",
|
||||
AbsPath: "/nonexistent-path-for-test/x.html",
|
||||
Size: 0,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for nonexistent abs path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "open") {
|
||||
t.Fatalf("expected open error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHTMLPublishZipEntry_CopyFailure(t *testing.T) {
|
||||
fio := readFailingFIO{readErr: errors.New("synthetic read failure")}
|
||||
zw := zip.NewWriter(io.Discard)
|
||||
defer zw.Close()
|
||||
|
||||
err := writeHTMLPublishZipEntry(fio, zw, htmlPublishCandidate{
|
||||
RelPath: "x.html",
|
||||
AbsPath: "fixtures/x.html",
|
||||
Size: 7,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when underlying Read fails")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "copy") {
|
||||
t.Fatalf("expected copy-stage error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishZip_EntryWriteFailureReturnsError(t *testing.T) {
|
||||
candidates := []htmlPublishCandidate{
|
||||
{RelPath: "x.html", AbsPath: "/nonexistent-path-for-test/x.html", Size: 0},
|
||||
}
|
||||
|
||||
archive, err := buildHTMLPublishZip(newTestFIO(), candidates)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got archive=%+v", archive)
|
||||
}
|
||||
if archive != nil {
|
||||
t.Fatalf("expected nil archive on error, got %+v", archive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHTMLPublishZipEntry_RejectsPathTraversal(t *testing.T) {
|
||||
zw := zip.NewWriter(io.Discard)
|
||||
defer zw.Close()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rel string
|
||||
}{
|
||||
{"parent traversal", "../etc/passwd"},
|
||||
{"absolute path", "/etc/passwd"},
|
||||
{"embedded traversal", "a/../../etc/passwd"},
|
||||
{"null byte", "evil\x00.html"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := writeHTMLPublishZipEntry(newTestFIO(), zw, htmlPublishCandidate{
|
||||
RelPath: c.rel,
|
||||
AbsPath: "fixtures/whatever",
|
||||
Size: 0,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for RelPath=%q", c.rel)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid zip entry name") {
|
||||
t.Fatalf("expected 'invalid zip entry name' error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user