mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
40 Commits
codex/cli-
...
feat/moder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d85e1cb06 | ||
|
|
94635e5ac9 | ||
|
|
b54eda23b0 | ||
|
|
c6e5748d93 | ||
|
|
3c0367d9c1 | ||
|
|
ce77383cb4 | ||
|
|
038dd256b2 | ||
|
|
39e1314f37 | ||
|
|
ec6744136a | ||
|
|
a520509cd8 | ||
|
|
005dc7aa35 | ||
|
|
03e93b4393 | ||
|
|
7e2a274bad | ||
|
|
ed3771a894 | ||
|
|
8d671709e9 | ||
|
|
3454823536 | ||
|
|
4b8b36250f | ||
|
|
18e2ec5681 | ||
|
|
7356292348 | ||
|
|
705f449472 | ||
|
|
4a8b64b5ae | ||
|
|
2e653d4f2d | ||
|
|
d2e10b39a3 | ||
|
|
6d3275f35d | ||
|
|
994ba94aba | ||
|
|
f60ff95c22 | ||
|
|
577c4df634 | ||
|
|
a0bdde46b0 | ||
|
|
b16f1d34f3 | ||
|
|
fc1aa02aa8 | ||
|
|
92bed1b424 | ||
|
|
7e794a19b5 | ||
|
|
5142fd0c65 | ||
|
|
3bff980d7a | ||
|
|
b1819e63b2 | ||
|
|
e3ba34df8f | ||
|
|
fe4d55ee88 | ||
|
|
800eeaf50e | ||
|
|
19e91ec7b0 | ||
|
|
e1e393146a |
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -61,6 +62,7 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
// --app-type is constrained to the lowercase enum (html / full_stack) by the
|
||||
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
|
||||
// a server concern and is intentionally not surfaced by the CLI.
|
||||
agent := envvars.AgentName()
|
||||
body := map[string]interface{}{
|
||||
"name": strings.TrimSpace(rctx.Str("name")),
|
||||
"app_type": rctx.Str("app-type"),
|
||||
@@ -71,5 +73,8 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
|
||||
body["icon_url"] = icon
|
||||
}
|
||||
if agent != "" {
|
||||
body["source_agent"] = 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_NAME", "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["source_agent"] != "doubao" {
|
||||
t.Fatalf("body.source_agent = %v, want doubao", sent["source_agent"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_WithoutAgentEnvVar(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "")
|
||||
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["source_agent"]; present {
|
||||
t.Fatalf("source_agent 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["source_agent"]; present {
|
||||
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
67
shortcuts/apps/apps_get.go
Normal file
67
shortcuts/apps/apps_get.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsGet fetches a single app's detail by app ID.
|
||||
var AppsGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+get",
|
||||
Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +get --app-id <app_id>",
|
||||
"Example: lark-cli apps +get --app-id <app_id> --dry-run",
|
||||
"Tip: extract app type with --jq '.data.app.app_type'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Get app detail (returns app_id, app_type, name, description, icon_url, created_at, updated_at, is_published)")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
data, err := rctx.CallAPITyped("GET", fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID)), nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
app, _ := data["app"].(map[string]interface{})
|
||||
if app == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
|
||||
fmt.Fprintf(w, "app_type: %v\n", app["app_type"])
|
||||
fmt.Fprintf(w, "name: %v\n", app["name"])
|
||||
if desc, ok := app["description"].(string); ok && desc != "" {
|
||||
fmt.Fprintf(w, "description: %s\n", desc)
|
||||
}
|
||||
fmt.Fprintf(w, "is_published: %v\n", app["is_published"])
|
||||
fmt.Fprintf(w, "updated_at: %v\n", app["updated_at"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
109
shortcuts/apps/apps_get_test.go
Normal file
109
shortcuts/apps/apps_get_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsGet_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_test",
|
||||
"app_type": "html",
|
||||
"name": "TestApp",
|
||||
"description": "A test application",
|
||||
"icon_url": "https://example.com/icon.svg",
|
||||
"is_published": true,
|
||||
"created_at": "2026-05-18T10:00:00Z",
|
||||
"updated_at": "2026-06-01T12:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsGet,
|
||||
[]string{"+get", "--app-id", "app_test", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "app_test") {
|
||||
t.Fatalf("stdout missing app_id: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "html") {
|
||||
t.Fatalf("stdout missing app_type: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsGet_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsGet,
|
||||
[]string{"+get", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected app-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsGet_EmptyAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsGet,
|
||||
[]string{"+get", "--app-id", "", "--as", "user"}, factory, stdout)
|
||||
requireAppsValidationProblem(t, err)
|
||||
}
|
||||
|
||||
func TestAppsGet_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsGet,
|
||||
[]string{"+get", "--app-id", "app_test", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_test") {
|
||||
t.Fatalf("dry-run missing API path: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsGet_PrettyOutput(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_test",
|
||||
"app_type": "html",
|
||||
"name": "PrettyApp",
|
||||
"description": "A pretty test app",
|
||||
"is_published": true,
|
||||
"updated_at": "2026-06-01T12:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsGet,
|
||||
[]string{"+get", "--app-id", "app_test", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"app_id:", "app_type:", "name:", "is_published:"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("pretty output missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,26 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsHTMLPublish packs --path as tar.gz and uploads + publishes via one multipart POST.
|
||||
// AppsHTMLPublish packs --path as tar.gz and publishes an HTML app.
|
||||
var AppsHTMLPublish = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+html-publish",
|
||||
Description: "Publish HTML to an app (single multipart POST returns the access URL)",
|
||||
Description: "Publish HTML to an app (returns url or release_id depending on app type)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./dist",
|
||||
@@ -70,7 +73,7 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)")
|
||||
dry.Desc("Upload tar.gz + publish HTML (returns url or release_id depending on app type)")
|
||||
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Set("content_type", "multipart/form-data")
|
||||
|
||||
@@ -119,8 +122,17 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
AppID: strings.TrimSpace(rctx.Str("app-id")),
|
||||
Path: strings.TrimSpace(rctx.Str("path")),
|
||||
}
|
||||
client := appsHTMLPublishAPI{runtime: rctx}
|
||||
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||||
|
||||
appType := queryAppType(ctx, rctx, spec.AppID)
|
||||
|
||||
var out map[string]interface{}
|
||||
var err error
|
||||
if appType == "modern_html" {
|
||||
out, err = runHTMLPublishTOS(ctx, rctx, spec)
|
||||
} else {
|
||||
client := appsHTMLPublishAPI{runtime: rctx}
|
||||
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -128,6 +140,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
if url, ok := out["url"].(string); ok && url != "" {
|
||||
fmt.Fprintf(w, "url: %s\n", url)
|
||||
}
|
||||
if rid, ok := out["release_id"].(string); ok && rid != "" {
|
||||
fmt.Fprintf(w, "release_id: %s\n", rid)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
@@ -256,3 +271,99 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
|
||||
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
|
||||
// tos_path for +release-create --tos-path.
|
||||
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), 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")
|
||||
}
|
||||
|
||||
tarball, err := buildHTMLPublishTarball(rctx.FileIO(), candidates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tarball.Size > maxHTMLPublishTarballBytes {
|
||||
return nil, appsValidationParamError("--path",
|
||||
"packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes).
|
||||
WithHint("reduce --path contents, remove unrelated large files, then retry")
|
||||
}
|
||||
|
||||
// Step 1: call pre_release to get TOS upload URL and tos_path.
|
||||
preReleasePath := fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(spec.AppID))
|
||||
preData, err := rctx.CallAPITyped("GET", preReleasePath, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kvs, _ := preData["kvs"].([]interface{})
|
||||
if len(kvs) == 0 {
|
||||
return nil, appsSubprocessEnvelopeError("pre_release returned no kvs")
|
||||
}
|
||||
kvm := make(map[string]string, len(kvs))
|
||||
for _, item := range kvs {
|
||||
kv, _ := item.(map[string]interface{})
|
||||
if kv == nil {
|
||||
continue
|
||||
}
|
||||
k, _ := kv["key"].(string)
|
||||
v, _ := kv["value"].(string)
|
||||
if k != "" {
|
||||
kvm[k] = v
|
||||
}
|
||||
}
|
||||
uploadURL := kvm["upload_url"]
|
||||
tosPath := kvm["tos_path"]
|
||||
if uploadURL == "" || tosPath == "" {
|
||||
return nil, appsSubprocessEnvelopeError("pre_release kvs missing upload_url or tos_path")
|
||||
}
|
||||
|
||||
// Step 2: upload tar.gz to TOS via presigned URL (bypasses Lark gateway).
|
||||
//nolint:forbidigo // presigned TOS upload bypasses the Lark gateway — raw http is required; not a Lark API call, so RuntimeContext.DoAPI does not apply.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(tarball.Body))
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "build TOS upload request").WithCause(err)
|
||||
}
|
||||
req.ContentLength = tarball.Size
|
||||
req.Header.Set("Content-Type", "application/gzip")
|
||||
resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // presigned TOS upload, see above.
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed").WithCause(err).WithRetryable()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "TOS upload failed: HTTP %d", resp.StatusCode).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Step 3: call release-create with tos_path to trigger deployment.
|
||||
releasePath := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(spec.AppID))
|
||||
releaseData, err := rctx.CallAPITyped("POST", releasePath, nil, map[string]interface{}{
|
||||
"tos_path": tosPath,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"release_id": common.GetString(releaseData, "release_id"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -6,10 +6,21 @@ package apps
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type fakeAppsHTMLPublishClient struct {
|
||||
@@ -582,3 +593,226 @@ func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ── runHTMLPublishTOS tests ──
|
||||
|
||||
// permissiveFIOProvider wraps permissiveFIO as a fileio.Provider for tests
|
||||
// that call runHTMLPublishTOS (which obtains FileIO via rctx.FileIO()).
|
||||
type permissiveFIOProvider struct{}
|
||||
|
||||
func (permissiveFIOProvider) Name() string { return "test-permissive" }
|
||||
func (permissiveFIOProvider) ResolveFileIO(context.Context) fileio.FileIO { return permissiveFIO{} }
|
||||
|
||||
// newTOSTestRuntime builds a RuntimeContext with httpmock registry and a
|
||||
// permissive FileIO provider, ready for runHTMLPublishTOS unit tests.
|
||||
func newTOSTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
factory.FileIOProvider = permissiveFIOProvider{}
|
||||
rt := common.TestNewRuntimeContextForAPI(
|
||||
context.Background(),
|
||||
&cobra.Command{Use: "+tos-test"},
|
||||
cfg, factory, core.AsUser,
|
||||
)
|
||||
return rt, reg
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_Success(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Start httptest server to accept the TOS upload.
|
||||
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
t.Errorf("TOS upload method = %s, want PUT", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/gzip" {
|
||||
t.Errorf("TOS upload Content-Type = %s, want application/gzip", ct)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer tosServer.Close()
|
||||
|
||||
// Register pre_release API stub.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"kvs": []interface{}{
|
||||
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
|
||||
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Register release-create API stub.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/releases",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"release_id": "rel_123",
|
||||
"status": "publishing",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
out, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if out["release_id"] != "rel_123" {
|
||||
t.Fatalf("release_id=%v, want rel_123", out["release_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_MissingIndexHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a file that is NOT named index.html.
|
||||
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
rt, _ := newTOSTestRuntime(t)
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: dir,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "index.html") {
|
||||
t.Fatalf("error should mention index.html, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_PreReleaseError(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Register pre_release API stub that returns an error code.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(99999),
|
||||
"msg": "internal server error",
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from pre_release API failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_MissingParams(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Register pre_release API stub that returns empty kvs list.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"kvs": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for empty kvs")
|
||||
}
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if !strings.Contains(problem.Message, "no kvs") {
|
||||
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_MissingParamsObject(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Register pre_release API stub that returns no kvs key at all.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing kvs")
|
||||
}
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if !strings.Contains(problem.Message, "no kvs") {
|
||||
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublishTOS_UploadFails(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
rt, reg := newTOSTestRuntime(t)
|
||||
|
||||
// Start httptest server that returns 500.
|
||||
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer tosServer.Close()
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"kvs": []interface{}{
|
||||
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
|
||||
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
|
||||
AppID: "app_tos",
|
||||
Path: site,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from TOS upload failure")
|
||||
}
|
||||
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
|
||||
if !strings.Contains(problem.Message, "500") {
|
||||
t.Fatalf("error should mention HTTP 500, got: %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -38,17 +39,58 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
|
||||
defaultTemplate = "nestjs-react-fullstack"
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.20-alpha.dd573f8"
|
||||
npmRegistry = "https://registry.npmmirror.com"
|
||||
metaRelPath = ".spark/meta.json"
|
||||
steeringRelPath = ".agent/skills/steering"
|
||||
seedReadme = "README.md"
|
||||
)
|
||||
|
||||
// Fallback committer identity written to the cloned repo's LOCAL git config when
|
||||
// no user.name/user.email is resolvable (from local, global, or system config).
|
||||
// The scaffold's `git commit` would otherwise fail with "please tell me who you
|
||||
// are"; an existing identity (e.g. the developer's global config) is respected.
|
||||
const (
|
||||
defaultGitUserName = "lark-cli-bot"
|
||||
defaultGitUserEmail = "lark-cli-bot@miaoda.com"
|
||||
)
|
||||
|
||||
// initRunner is the commandRunner used by +init. Package-level so unit tests
|
||||
// can swap in a fakeCommandRunner. Production uses execCommandRunner.
|
||||
var initRunner commandRunner = execCommandRunner{}
|
||||
|
||||
// appTypePolicy captures the per-app-type control points +init toggles, keeping
|
||||
// each knob out of the inline `appType == "..."` checks that would otherwise be
|
||||
// scattered through the flow. Add a field here (and set it in appTypePolicies)
|
||||
// for each new control point rather than threading another type comparison
|
||||
// through appsInitExecute.
|
||||
type appTypePolicy struct {
|
||||
// skipInstall passes --skip-install to `npx ... app init`, so scaffolding
|
||||
// runs no dependency install.
|
||||
skipInstall bool
|
||||
// skipEnvPull skips the post-init `+env-pull` step, on both the fresh-init
|
||||
// tail and the already-initialized refresh path.
|
||||
skipEnvPull bool
|
||||
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
|
||||
// the non-empty (`app sync`) scaffold path.
|
||||
skipSkillsSync bool
|
||||
}
|
||||
|
||||
// appTypePolicies maps an app_type to its +init control strategy. Types absent
|
||||
// from the map get the zero-value policy (install runs, env is pulled, skills
|
||||
// are synced).
|
||||
var appTypePolicies = map[string]appTypePolicy{
|
||||
// modern_html is a static HTML site: no dependencies to install, no startup
|
||||
// env vars to pull, and no steering skills to sync.
|
||||
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true},
|
||||
}
|
||||
|
||||
// policyForAppType returns the +init control strategy for appType. Unlisted
|
||||
// types (including "") get the zero-value policy.
|
||||
func policyForAppType(appType string) appTypePolicy {
|
||||
return appTypePolicies[appType]
|
||||
}
|
||||
|
||||
// AppsInit initializes an app's code and local development environment.
|
||||
var AppsInit = common.Shortcut{
|
||||
Service: appsService,
|
||||
@@ -75,7 +117,7 @@ var AppsInit = common.Shortcut{
|
||||
// check lives in Validate (typed validation error -> exit 2).
|
||||
{Name: "app-id", Desc: "app ID"},
|
||||
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
|
||||
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
|
||||
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
@@ -85,14 +127,13 @@ 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)
|
||||
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)).
|
||||
Set("checkout", "git checkout "+defaultInitBranch).
|
||||
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
|
||||
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --app-type <appType> --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, appID, miaodaCLIPkg)).
|
||||
Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
|
||||
Set("template", template).
|
||||
Set("template", "derived from queryAppType (fallback: full_stack)").
|
||||
Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
|
||||
dir, err := resolveTargetPath(rctx, appID)
|
||||
if err != nil {
|
||||
@@ -122,20 +163,6 @@ func defaultCloneDir(appID string) string {
|
||||
return filepath.Join(".", appID)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
|
||||
return t
|
||||
}
|
||||
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
|
||||
return defaultTemplate
|
||||
}
|
||||
|
||||
// initLogf writes a one-line progress message to stderr. stdout stays reserved
|
||||
// for the structured JSON envelope, so progress never pollutes it. Callers must
|
||||
// never pass a raw repository_url (it may embed a token) — pass step names,
|
||||
@@ -294,6 +321,34 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureGitIdentity guarantees the cloned repo has a committer identity so the
|
||||
// scaffold's `git commit` cannot fail with "please tell me who you are". It sets
|
||||
// the repo-LOCAL user.name/user.email to the lark-cli-bot defaults ONLY when
|
||||
// each is not already resolvable from local/global/system config, so a
|
||||
// developer's existing identity is never overwritten. Each key is handled
|
||||
// independently (a machine with only user.name set still gets a default email).
|
||||
func ensureGitIdentity(ctx context.Context, dir string) error {
|
||||
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
|
||||
}
|
||||
|
||||
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
|
||||
// resolves to no value. `git config --get` exits non-zero (or prints nothing)
|
||||
// when the key is unset at every scope; any resolved value (including one
|
||||
// inherited from global/system) is left untouched.
|
||||
func ensureGitConfigValue(ctx context.Context, dir, key, fallback string) error {
|
||||
stdout, _, err := initRunner.Run(ctx, dir, "git", "config", "--get", key)
|
||||
if err == nil && strings.TrimSpace(stdout) != "" {
|
||||
return nil // already configured at some scope — respect it
|
||||
}
|
||||
if _, stderr, e := initRunner.Run(ctx, dir, "git", "config", key, fallback); e != nil {
|
||||
return appsExternalToolError(e, "git config %s failed: %s", key, gitErr(stderr, e))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasSteeringSkills reports whether <dir>/.agent/skills/steering exists as a dir.
|
||||
func hasSteeringSkills(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, steeringRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
|
||||
@@ -326,34 +381,54 @@ 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) {
|
||||
func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) {
|
||||
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(appType, appID, sourcePath)
|
||||
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
|
||||
}
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
}
|
||||
return scaffoldKindUpgrade, nil
|
||||
}
|
||||
|
||||
// scaffoldInitArgs builds the npx argument list for `app init`.
|
||||
// appType from queryAppType is passed as --app-type; falls back to "full_stack"
|
||||
// when empty. sourcePath is appended as --source-path when non-empty.
|
||||
// --skip-install is appended per the app_type's policy (see appTypePolicy):
|
||||
// types whose policy sets skipInstall (e.g. modern_html) skip the dependency
|
||||
// install; others run it as usual.
|
||||
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
|
||||
base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"}
|
||||
at := appType
|
||||
if at == "" {
|
||||
at = "full_stack"
|
||||
}
|
||||
base = append(base, "--app-type", at, "--app-id", appID)
|
||||
if sourcePath != "" {
|
||||
base = append(base, "--source-path", sourcePath)
|
||||
}
|
||||
if policyForAppType(appType).skipInstall {
|
||||
base = append(base, "--skip-install")
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// 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,6 +520,9 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
appType := queryAppType(ctx, rctx, appID)
|
||||
policy := policyForAppType(appType)
|
||||
|
||||
// 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.
|
||||
@@ -457,6 +535,19 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
"committed": false,
|
||||
"pushed": false,
|
||||
}
|
||||
if appType != "" {
|
||||
out["app_type"] = appType
|
||||
}
|
||||
if policy.skipEnvPull {
|
||||
out["env_pulled"] = false
|
||||
out["env_pull_skipped"] = true
|
||||
out["message"] = "Repository already initialized. You can start developing."
|
||||
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 == ""
|
||||
@@ -514,8 +605,15 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
}
|
||||
|
||||
// Ensure a committer identity exists before the scaffold commit; only sets
|
||||
// repo-local defaults when none is configured (existing identity is kept).
|
||||
if err := ensureGitIdentity(ctx, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
|
||||
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
|
||||
sourcePath := strings.TrimSpace(rctx.Str("source-path"))
|
||||
scaffold, err := runScaffold(ctx, dir, appID, appType, sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -530,15 +628,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 +636,38 @@ 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 appType != "" {
|
||||
out["app_type"] = appType
|
||||
}
|
||||
|
||||
if policy.skipEnvPull {
|
||||
out["env_pulled"] = false
|
||||
out["env_pull_skipped"] = true
|
||||
} else {
|
||||
initLogf(rctx, "Pulling local environment variables...")
|
||||
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 {
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
|
||||
} else {
|
||||
if policy.skipEnvPull {
|
||||
fmt.Fprintln(w, " (env pull skipped)")
|
||||
} else if envPulled, _ := out["env_pulled"].(bool); envPulled {
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", out["env_file"])
|
||||
} else if envPullErr, ok := out["env_pull_error"].(string); ok {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -20,46 +20,20 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// testRuntimeWithDir builds a *common.RuntimeContext whose backing cobra command
|
||||
// has string flags "dir" (=dirFlag) and "template" (=defaultTemplate) registered,
|
||||
// mirroring how +init reads them at runtime via rctx.Str.
|
||||
// has a string flag "dir" (=dirFlag) registered, mirroring how +init reads it
|
||||
// at runtime via rctx.Str.
|
||||
func testRuntimeWithDir(t *testing.T, dirFlag string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "init"}
|
||||
cmd.Flags().String("dir", dirFlag, "")
|
||||
cmd.Flags().String("template", defaultTemplate, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
// testRuntimeWithTemplate builds a *common.RuntimeContext with "dir" and
|
||||
// "template" string flags registered, mirroring +init's runtime flag set. The
|
||||
// template flag is registered with an empty default (matching the real flag,
|
||||
// which no longer carries Default: defaultTemplate); pass tpl="" to model an
|
||||
// omitted --template and a non-empty tpl to model an explicit one.
|
||||
func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "init"}
|
||||
cmd.Flags().String("dir", dirFlag, "")
|
||||
cmd.Flags().String("template", tpl, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
func TestResolveTemplate(t *testing.T) {
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" {
|
||||
t.Errorf("explicit --template = %q, want foo", got)
|
||||
}
|
||||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); 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 {
|
||||
t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTargetPath(t *testing.T) {
|
||||
got, err := resolveTargetPath(testRuntimeWithDir(t, ""), "app_x")
|
||||
if err != nil {
|
||||
@@ -261,12 +235,12 @@ 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", "", "")
|
||||
if err != nil || kind != "init" {
|
||||
t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err)
|
||||
}
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", "nestjs-react-fullstack", "--app-id", "app_x") {
|
||||
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
t.Errorf("app init not invoked with expected args: %v", f.calls)
|
||||
}
|
||||
if c != nil && containsAll(c, "--local") {
|
||||
@@ -280,7 +254,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", "", "")
|
||||
if err != nil || kind != "upgrade" {
|
||||
t.Fatalf("kind=%q err=%v, want upgrade", kind, err)
|
||||
}
|
||||
@@ -294,12 +268,24 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_NonEmpty_ModernHTML_SkipsSyncEvenWithoutSteering(t *testing.T) {
|
||||
dir := t.TempDir() // no steering dir → sync would run for non-modern_html
|
||||
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", "modern_html", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
|
||||
t.Error("skills sync must be skipped for modern_html regardless of steering dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
|
||||
@@ -313,7 +299,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", "", ""); err == nil {
|
||||
t.Error("app init failure must propagate")
|
||||
}
|
||||
}
|
||||
@@ -342,12 +328,12 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
|
||||
if _, ok := data["npx_skipped"]; ok {
|
||||
t.Error("npx_skipped must be removed")
|
||||
}
|
||||
// --template is omitted here, so resolveTemplate falls back to
|
||||
// defaultTemplate and `app init` must still receive --template nestjs-react-fullstack.
|
||||
// appType is empty, so scaffoldInitArgs falls back to "full_stack"
|
||||
// and `app init` must still receive --template full_stack.
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Error("npx scaffold not invoked")
|
||||
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", defaultTemplate, "--app-id", "app_x") {
|
||||
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
t.Errorf("app init missing expected --template fallback args: %v", c)
|
||||
} else if containsAll(c, "--local") {
|
||||
t.Errorf("app init must NOT carry --local: %v", c)
|
||||
@@ -751,22 +737,6 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
|
||||
}
|
||||
|
||||
func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
var tmpl *common.Flag
|
||||
for i := range AppsInit.Flags {
|
||||
if AppsInit.Flags[i].Name == "template" {
|
||||
tmpl = &AppsInit.Flags[i]
|
||||
}
|
||||
}
|
||||
if tmpl == nil {
|
||||
t.Fatal("--template flag missing")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(tmpl.Desc), "scaffold") {
|
||||
t.Errorf("--template Desc still mentions scaffold: %q", tmpl.Desc)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(tmpl.Desc), "code-init") {
|
||||
t.Errorf("--template Desc should use code-init wording: %q", tmpl.Desc)
|
||||
}
|
||||
|
||||
// The --dry-run output is a flat object (DryRunAPI marshals to top-level keys
|
||||
// description/scaffold/api/...), NOT wrapped in {"data":...}, so parse stdout
|
||||
// directly rather than via parseEnvelopeData.
|
||||
@@ -787,9 +757,8 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
t.Error("dry-run must keep machine-contract key `scaffold`")
|
||||
} else if !strings.Contains(scaffold, "skills sync --local") {
|
||||
t.Errorf("dry-run scaffold string must show --local on skills sync: %q", scaffold)
|
||||
} else if strings.Contains(scaffold, "app init --template nestjs-react-fullstack --app-id app_x --local") ||
|
||||
strings.Contains(scaffold, "app sync --local") {
|
||||
t.Errorf("dry-run scaffold string must NOT show --local on app init / app sync: %q", scaffold)
|
||||
} else if strings.Contains(scaffold, "app sync --local") {
|
||||
t.Errorf("dry-run scaffold string must NOT show --local on app sync: %q", scaffold)
|
||||
}
|
||||
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
@@ -1250,7 +1219,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", "", ""); err == nil {
|
||||
t.Error("npx app sync failure must surface as an error")
|
||||
}
|
||||
}
|
||||
@@ -1630,7 +1599,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", "", "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from failing git subprocess")
|
||||
}
|
||||
@@ -1645,3 +1614,368 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_HtmlPassesTemplate(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", "html", "")
|
||||
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", "html") {
|
||||
t.Errorf("expected --template html in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_ModernHtmlPassesTemplate(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", "modern_html", "")
|
||||
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", "modern_html") {
|
||||
t.Errorf("expected --template modern_html in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_EmptyAppTypeFallback(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", "", "")
|
||||
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", "full_stack") {
|
||||
t.Errorf("expected --template full_stack in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScaffold_FullStackPassesTemplate(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", "full_stack", "")
|
||||
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", "full_stack") {
|
||||
t.Errorf("expected --template full_stack in args: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_WithAppType(t *testing.T) {
|
||||
args := scaffoldInitArgs("modern_html", "app_x", "")
|
||||
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x") {
|
||||
t.Errorf("expected --template modern_html --app-id app_x, got %v", args)
|
||||
}
|
||||
// modern_html skips dependency install.
|
||||
if !containsAll(args, "--skip-install") {
|
||||
t.Errorf("expected --skip-install for modern_html, got %v", args)
|
||||
}
|
||||
for _, a := range args {
|
||||
if a == "--source-path" {
|
||||
t.Errorf("--source-path must not appear when sourcePath is empty: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyForAppType(t *testing.T) {
|
||||
// modern_html decouples all control points: skip install, env-pull, skills sync.
|
||||
if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
|
||||
t.Errorf("modern_html policy = %+v, want all skip flags set", p)
|
||||
}
|
||||
// Unlisted types (including "") get the zero-value policy: everything runs.
|
||||
for _, at := range []string{"full_stack", "", "backend"} {
|
||||
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
|
||||
t.Errorf("policy for %q = %+v, want zero value", at, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_SkipInstallOnlyForModernHTML(t *testing.T) {
|
||||
// Non-modern_html types run the install step (no --skip-install).
|
||||
for _, at := range []string{"full_stack", "", "backend"} {
|
||||
args := scaffoldInitArgs(at, "app_x", "")
|
||||
for _, a := range args {
|
||||
if a == "--skip-install" {
|
||||
t.Errorf("--skip-install must not appear for app-type %q: %v", at, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_EmptyFallback(t *testing.T) {
|
||||
args := scaffoldInitArgs("", "app_x", "")
|
||||
if !containsAll(args, "--app-type", "full_stack", "--app-id", "app_x") {
|
||||
t.Errorf("expected --template full_stack fallback, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldInitArgs_WithSourcePath(t *testing.T) {
|
||||
args := scaffoldInitArgs("modern_html", "app_x", "/path/to/src")
|
||||
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x", "--source-path", "/path/to/src") {
|
||||
t.Errorf("expected --source-path /path/to/src, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// configSetValue finds a `git config <key> <value>` SET call (not a `--get`)
|
||||
// in the recorded fake calls and returns its value.
|
||||
func configSetValue(calls [][]string, key string) (string, bool) {
|
||||
for _, c := range calls {
|
||||
if len(c) >= 5 && c[1] == "git" && c[2] == "config" && c[3] == key {
|
||||
return c[4], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
|
||||
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
|
||||
t.Errorf("user.name set = (%q,%v), want %q", v, ok, defaultGitUserName)
|
||||
}
|
||||
if v, ok := configSetValue(f.calls, "user.email"); !ok || v != defaultGitUserEmail {
|
||||
t.Errorf("user.email set = (%q,%v), want %q", v, ok, defaultGitUserEmail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
|
||||
// `git config --get` returns a value → identity resolvable, nothing is set.
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"git config": {stdout: "Existing Dev\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := configSetValue(f.calls, "user.name"); ok {
|
||||
t.Error("user.name must not be overwritten when already configured")
|
||||
}
|
||||
if _, ok := configSetValue(f.calls, "user.email"); ok {
|
||||
t.Error("user.email must not be overwritten when already configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"git config": {stderr: "boom", err: errors.New("exit 1")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
|
||||
t.Error("expected error when git config set fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_WithAppType_FreshClone(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("http://u:t@h/app_typed.git"),
|
||||
"git clone": {},
|
||||
"git checkout": {},
|
||||
"git ls-files": {stdout: ""},
|
||||
"git status": {stdout: " A src/app.ts\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
// Register a meta mock so queryAppType returns "modern_html"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_typed",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_typed",
|
||||
"app_type": "MODERN_HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := parseEnvelopeData(t, stdout)
|
||||
if data["app_type"] != "modern_html" {
|
||||
t.Errorf("app_type = %v, want modern_html", data["app_type"])
|
||||
}
|
||||
// Verify the scaffold used --template modern_html
|
||||
c := findCall(f.calls, "npx", "-y")
|
||||
if c == nil {
|
||||
t.Fatal("npx not called")
|
||||
}
|
||||
if !containsAll(c, "--app-type", "modern_html") {
|
||||
t.Errorf("expected --template modern_html, got %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_ModernHtml_SkipsEnvPull(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("https://git.test/app_mh.git"),
|
||||
"git clone": {},
|
||||
"git checkout": {},
|
||||
"git ls-files": {stdout: ""},
|
||||
"npx -y": {},
|
||||
"git status": {stdout: ""},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_mh",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_mh",
|
||||
"app_type": "MODERN_HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_mh", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := parseEnvelopeData(t, stdout)
|
||||
if data["env_pull_skipped"] != true {
|
||||
t.Errorf("env_pull_skipped = %v, want true", data["env_pull_skipped"])
|
||||
}
|
||||
if data["env_pulled"] != false {
|
||||
t.Errorf("env_pulled = %v, want false", data["env_pulled"])
|
||||
}
|
||||
// Verify env-pull was NOT called
|
||||
for _, c := range f.calls {
|
||||
if len(c) >= 3 && c[2] == "apps" && len(c) >= 4 && c[3] == "+env-pull" {
|
||||
t.Fatal("env-pull should not be called for modern_html")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_AlreadyInitialized_ModernHtml_SkipsEnvPull(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_mh2"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_mh2",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_mh2",
|
||||
"app_type": "MODERN_HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_mh2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := parseEnvelopeData(t, stdout)
|
||||
if data["scaffold"] != "already_initialized" {
|
||||
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
|
||||
}
|
||||
if data["env_pull_skipped"] != true {
|
||||
t.Errorf("env_pull_skipped = %v, want true", data["env_pull_skipped"])
|
||||
}
|
||||
if len(f.calls) != 0 {
|
||||
t.Errorf("no commands should be called for already-initialized modern_html, got %v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_WithAppType_AlreadyInitialized(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_typed2"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envFile := filepath.Join(abs, ".env.local")
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
// Register meta mock so queryAppType returns "html"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_typed2",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_typed2",
|
||||
"app_type": "HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := parseEnvelopeData(t, stdout)
|
||||
if data["scaffold"] != "already_initialized" {
|
||||
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
|
||||
}
|
||||
if data["app_type"] != "html" {
|
||||
t.Errorf("app_type = %v, want html", data["app_type"])
|
||||
}
|
||||
}
|
||||
|
||||
52
shortcuts/apps/apps_meta.go
Normal file
52
shortcuts/apps/apps_meta.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// appInfo represents the app object returned by GET /open-apis/spark/v1/apps/{appID}.
|
||||
type appInfo struct {
|
||||
AppID string `json:"app_id"`
|
||||
AppType string `json:"app_type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IconURL string `json:"icon_url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
}
|
||||
|
||||
// queryAppType fetches the app's type string from the server via
|
||||
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
|
||||
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
|
||||
// to lowercase. Returns "" when the API is unavailable or returns an
|
||||
// error — callers fall back to legacy behavior.
|
||||
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
appRaw, _ := data["app"].(map[string]interface{})
|
||||
if appRaw == nil {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(appRaw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var info appInfo
|
||||
if err := json.Unmarshal(b, &info); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(info.AppType)
|
||||
}
|
||||
148
shortcuts/apps/apps_meta_test.go
Normal file
148
shortcuts/apps/apps_meta_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
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 TestQueryAppType_Success(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_test",
|
||||
"app_type": "MODERN_HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_test")
|
||||
if result != "modern_html" {
|
||||
t.Errorf("queryAppType = %q, want modern_html", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_FullStack(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_fs",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_fs",
|
||||
"app_type": "FULL_STACK",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_fs")
|
||||
if result != "full_stack" {
|
||||
t.Errorf("queryAppType = %q, want full_stack", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_Html(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_html",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_html",
|
||||
"app_type": "HTML",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_html")
|
||||
if result != "html" {
|
||||
t.Errorf("queryAppType = %q, want html", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_APIError(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_bad",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_bad")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty on error", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_MissingAppObject(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_no",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_no")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app object missing", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAppType_EmptyAppType(t *testing.T) {
|
||||
rt, reg := newMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_empty",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": "app_empty",
|
||||
"app_type": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := queryAppType(context.Background(), rt, "app_empty")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
|
||||
}
|
||||
}
|
||||
@@ -56,9 +56,10 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
out := map[string]interface{}{
|
||||
"release_id": common.GetString(data, "release_id"),
|
||||
"status": common.GetString(data, "status"),
|
||||
"sync": common.GetBool(data, "sync"),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "release_id: %s\nstatus: %s\n", out["release_id"], out["status"])
|
||||
fmt.Fprintf(w, "release_id: %s\nstatus: %s\nsync: %v\n", out["release_id"], out["status"], out["sync"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -105,3 +105,45 @@ func TestAppsReleaseCreateExecute_Success(t *testing.T) {
|
||||
t.Errorf("status = %v, want publishing", env.Data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseCreate_SyncField(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newReleaseCreateRuntimeContext(t, "app_sync", "main")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_sync/releases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release_id": "456",
|
||||
"status": "publishing",
|
||||
"sync": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := AppsReleaseCreate.Execute(context.Background(), rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
|
||||
}
|
||||
if env.Data["release_id"] != "456" {
|
||||
t.Errorf("release_id = %v, want 456", env.Data["release_id"])
|
||||
}
|
||||
if env.Data["status"] != "publishing" {
|
||||
t.Errorf("status = %v, want publishing", env.Data["status"])
|
||||
}
|
||||
if env.Data["sync"] != true {
|
||||
t.Errorf("sync = %v, want true", env.Data["sync"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
|
||||
|
||||
return []common.Shortcut{
|
||||
AppsCreate,
|
||||
AppsGet,
|
||||
AppsUpdate,
|
||||
AppsList,
|
||||
AppsAccessScopeSet,
|
||||
|
||||
@@ -21,10 +21,10 @@ import (
|
||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||
// - 3 plugin(install/uninstall/list)= 63。
|
||||
func TestAppsShortcuts_Returns63(t *testing.T) {
|
||||
func TestAppsShortcuts_Returns64(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 63 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 63", len(got))
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 64", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -431,6 +431,11 @@ func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]in
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
|
||||
req.ExtraOpts = append(req.ExtraOpts, optFn)
|
||||
}
|
||||
// TODO: remove PPE headers once testing is complete and promoted to production.
|
||||
ppeHeaders := http.Header{}
|
||||
ppeHeaders.Set("x-use-ppe", "1")
|
||||
ppeHeaders.Set("x-tt-env", "ppe_miaoda_lark_cli")
|
||||
req.ExtraOpts = append(req.ExtraOpts, larkcore.WithHeaders(ppeHeaders))
|
||||
return req
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ metadata:
|
||||
|---|---|---|
|
||||
| 创建**新**应用资产、拿 app_id | `+create` | [`lark-apps-create.md`](references/lark-apps-create.md) |
|
||||
| 找已有 app_id、按名字过滤应用 | `+list --keyword <name>` | [`lark-apps-list.md`](references/lark-apps-list.md) |
|
||||
| 查单个应用详情(类型、名称、发布状态等) | `+get --app-id <app_id>` | [`lark-apps-get.md`](references/lark-apps-get.md) |
|
||||
| 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) |
|
||||
| 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) |
|
||||
| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md),含端到端流程和领域规则 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) |
|
||||
|
||||
43
skills/lark-apps/references/lark-apps-get.md
Normal file
43
skills/lark-apps/references/lark-apps-get.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# apps +get
|
||||
|
||||
按 app_id 查询单个应用详情。运行时命令事实以 `lark-cli apps +get --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
需要查看一个应用的类型、名称、描述、发布状态等详情时使用。如果只是按应用名模糊搜索定位 app_id,用 `+list --keyword`。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--app-id`。
|
||||
- 返回应用的完整信息:`app_id`、`app_type`、`name`、`description`、`icon_url`、`created_at`、`updated_at`、`is_published`。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +get --app-id app_xxx
|
||||
lark-cli apps +get --app-id app_xxx --dry-run
|
||||
lark-cli apps +get --app-id app_xxx -q '.data.app.app_type'
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 成功读取 `data.app` 对象,包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `app_id` | string | 应用唯一标识 |
|
||||
| `app_type` | string | 应用类型(如 HTML、FULL_STACK、MODERN_HTML) |
|
||||
| `name` | string | 应用显示名称 |
|
||||
| `description` | string | 应用功能说明 |
|
||||
| `icon_url` | string | 应用图标 URL |
|
||||
| `created_at` | string | 创建时间(ISO 8601 UTC) |
|
||||
| `updated_at` | string | 最后更新时间(ISO 8601 UTC) |
|
||||
| `is_published` | boolean | 是否已发布 |
|
||||
|
||||
- pretty 输出展示核心字段:`app_id`、`app_type`、`name`、`is_published`、`updated_at`。
|
||||
- `is_published=true` 只代表应用历史上有发布版本,不代表最新代码已部署。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 用户已有 `app_id` 想查看详情时用 `+get`;只有应用名时用 `+list --keyword`。
|
||||
- 不要把 `cli_` 开头的飞书应用 ID 传给 `+get`,只接受 `app_` 开头的应用 ID。
|
||||
@@ -23,8 +23,13 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 成功默认 JSON envelope 只关心 `data.url`;这是本轮 HTML 发布后的发布态访问链接。
|
||||
- pretty 输出为 `url: <url>`,适合人看;自动化取字段用 JSON 或 `--jq '.data.url'`。
|
||||
根据应用类型,输出字段不同:
|
||||
|
||||
- **静态 HTML 应用**:`data.url` 是本轮发布后的访问链接,一步完成发布。
|
||||
- **其他 HTML 应用**:`data.release_id` 是发布标识,命令内部已完成产物上传和发布创建。用 `+release-get --app-id <app_id> --release-id <release_id>` 轮询发布状态直到 `finished`。
|
||||
|
||||
判断走哪条路径:有 `url` 字段说明已直接发布完成;有 `release_id` 字段说明需要用 `+release-get` 轮询。
|
||||
|
||||
- 业务失败如构建失败、应用不存在通常带 `error.hint`;优先转述 hint。网络/服务端失败则建议稍后重试。
|
||||
|
||||
## 链接边界
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
- 必填:`--app-id`。
|
||||
- 可选:`--dir`,clone 目标目录;省略时默认 `./<app-id>`。
|
||||
- 可选:`--template`,空仓库脚手架模板;省略时当前回退 `nestjs-react-fullstack`。
|
||||
- 固定 checkout 分支:`sprint/default`。
|
||||
- `+init` 会初始化 Git 凭证、clone 仓库、切到工作分支并生成/同步本地项目。
|
||||
|
||||
@@ -18,7 +17,7 @@
|
||||
|
||||
```bash
|
||||
lark-cli apps +init --app-id app_xxx --dir ./my-app
|
||||
lark-cli apps +init --app-id app_xxx --dir /absolute/path/my-app --template nestjs-react-fullstack
|
||||
lark-cli apps +init --app-id app_xxx --dir /absolute/path/my-app
|
||||
lark-cli apps +init --app-id app_xxx --dir ./my-app --dry-run
|
||||
```
|
||||
|
||||
|
||||
@@ -21,8 +21,10 @@ lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 成功读取 `data.release_id` 和 `data.status`;`release_id` 是后续 `+release-get` 的入参。
|
||||
- 成功读取 `data.release_id`、`data.status` 和 `data.sync`;`release_id` 是后续 `+release-get` 的入参。
|
||||
- `sync=true` 表示同步部署(服务端等待部署完成后才返回),`sync=false` 或缺失表示异步部署。
|
||||
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min,整体超时时间大约 5min。
|
||||
- `status=finished` 表示部署已完成(同步部署时可能直接返回此状态)。
|
||||
- `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
Reference in New Issue
Block a user