mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
11 Commits
feat/apps-
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0eaed3354 | ||
|
|
2424d05b01 | ||
|
|
cf9e8d512d | ||
|
|
9d7f1e4e6b | ||
|
|
be7c05cc97 | ||
|
|
9a85ffb4d2 | ||
|
|
ff65e614e7 | ||
|
|
9f2fe50f4a | ||
|
|
7d1164dcb4 | ||
|
|
2362437de9 | ||
|
|
8a5c1dc547 |
@@ -83,7 +83,7 @@ var AppsEnvPull = common.Shortcut{
|
||||
|
||||
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
return withAppsHint(err, envPullAPIErrorHint(err, appID))
|
||||
}
|
||||
|
||||
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
|
||||
@@ -126,6 +126,27 @@ func envPullVarsBody() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func envPullAPIErrorHint(err error, appID string) string {
|
||||
if isEnvPullDevDBNotInitializedError(err) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
appID = "<app_id>"
|
||||
}
|
||||
return fmt.Sprintf("dev database is not initialized; preview creation with `lark-cli apps +db-env-create --app-id %s --environment dev --dry-run`, then run `lark-cli apps +db-env-create --app-id %s --environment dev --sync-data --yes` after confirming the irreversible split", appID, appID)
|
||||
}
|
||||
return appIDListHint
|
||||
}
|
||||
|
||||
func isEnvPullDevDBNotInitializedError(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(p.Message)
|
||||
return strings.Contains(message, "multi-environment database is not initialized") ||
|
||||
(strings.Contains(message, "invalid db branch") && strings.Contains(message, "dev"))
|
||||
}
|
||||
|
||||
func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
|
||||
@@ -592,6 +592,38 @@ func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_DevDBNotInitializedHintPointsToDBEnvCreate(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
Body: map[string]interface{}{
|
||||
"code": -1,
|
||||
"msg": "Multi-environment database is not initialized for this app. Invalid DB Branch:dev",
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvPullBody(t, req)
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvPull,
|
||||
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
|
||||
factory, stdout,
|
||||
)
|
||||
p := requireAppsAPIProblem(t, err)
|
||||
if p.Code != -1 {
|
||||
t.Fatalf("code = %d, want -1", p.Code)
|
||||
}
|
||||
for _, want := range []string{"+db-env-create", "--app-id app_x", "--environment dev", "--dry-run", "--yes"} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Fatalf("hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
if strings.Contains(p.Hint, "apps +list") {
|
||||
t.Fatalf("hint should not point to app-id/list recovery for missing dev database: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
projectDir := t.TempDir()
|
||||
|
||||
@@ -31,6 +31,7 @@ var AppsPluginInstall = common.Shortcut{
|
||||
Description: "Install a plugin package (download, extract, update package.json)",
|
||||
Risk: "write",
|
||||
ConditionalScopes: []string{"spark:app:read"},
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate",
|
||||
@@ -67,6 +68,11 @@ var AppsPluginInstall = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key := strings.TrimSpace(rctx.Str("name")); key != "" {
|
||||
if err := validatePluginKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return pluginCheckProjectDir(projectPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -133,7 +139,10 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP
|
||||
}
|
||||
|
||||
// Extract to node_modules
|
||||
destDir := filepath.Join(projectPath, "node_modules", key)
|
||||
destDir, err := secureModulePath(projectPath, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract.
|
||||
return appsFileIOError(err, "cannot clean %s", destDir)
|
||||
}
|
||||
@@ -195,7 +204,7 @@ func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectP
|
||||
continue
|
||||
}
|
||||
if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil {
|
||||
return fmt.Errorf("install %s: %w", key, err)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "install %s failed", key).WithCause(err)
|
||||
}
|
||||
installed++
|
||||
}
|
||||
@@ -217,7 +226,7 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string
|
||||
}
|
||||
|
||||
// Extract to a temp dir first to read package.json
|
||||
tmpDir, err := os.MkdirTemp("", "plugin-local-*") //nolint:forbidigo
|
||||
tmpDir, err := os.MkdirTemp(projectPath, ".plugin-tmp-*") //nolint:forbidigo // same FS as node_modules to avoid EXDEV on Rename
|
||||
if err != nil {
|
||||
return appsFileIOError(err, "cannot create temp dir")
|
||||
}
|
||||
@@ -246,7 +255,10 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string
|
||||
}
|
||||
|
||||
// Move to node_modules
|
||||
destDir := filepath.Join(projectPath, "node_modules", key)
|
||||
destDir, err := secureModulePath(projectPath, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo
|
||||
return appsFileIOError(err, "cannot clean %s", destDir)
|
||||
}
|
||||
@@ -354,6 +366,9 @@ func pluginFindVersionInItems(data map[string]interface{}, key, version string)
|
||||
|
||||
// pluginDownloadPackage downloads a plugin .tgz via the download_package API.
|
||||
// The endpoint is POST with JSON body {plugin_key, plugin_version}.
|
||||
|
||||
const pluginDownloadMaxBytes = 10 * 1024 * 1024
|
||||
|
||||
func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) {
|
||||
apiPath := apiBasePath + "/plugin/versions/download_package"
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -379,7 +394,7 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key
|
||||
WithRetryable()
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
hint := "check plugin key and version spelling"
|
||||
if resp.StatusCode == 403 {
|
||||
hint = "download token may have expired; retry the install to get a fresh token"
|
||||
@@ -389,5 +404,16 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)).
|
||||
WithHint(hint)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, pluginDownloadMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err).
|
||||
WithHint("check network connectivity and retry").
|
||||
WithRetryable().
|
||||
WithCause(err)
|
||||
}
|
||||
if len(data) > pluginDownloadMaxBytes {
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "plugin package %s@%s exceeds %d MB size limit", key, version, pluginDownloadMaxBytes/(1024*1024)).
|
||||
WithHint("contact plugin maintainer to reduce package size")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestPluginInstall_SinglePlugin(t *testing.T) {
|
||||
|
||||
// Verify file extracted
|
||||
manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json")
|
||||
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo
|
||||
if _, err := os.Stat(manifestPath); err != nil {
|
||||
t.Fatalf("manifest.json not extracted: %v", err)
|
||||
}
|
||||
|
||||
@@ -92,8 +92,8 @@ func TestPluginInstall_AlreadyInstalled(t *testing.T) {
|
||||
})
|
||||
// Create an existing installed plugin with package.json containing version
|
||||
pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
|
||||
os.MkdirAll(pkgDir, 0o755)
|
||||
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
@@ -126,7 +126,7 @@ func TestPluginExtractTGZ(t *testing.T) {
|
||||
t.Fatalf("extract error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo
|
||||
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("manifest.json not extracted: %v", err)
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestPluginExtractTGZ_PathTraversal(t *testing.T) {
|
||||
if err := pluginExtractTGZ(&buf, destDir); err != nil {
|
||||
t.Fatalf("extract should not error, but skip bad entries: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo
|
||||
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil {
|
||||
t.Error("path traversal should have been blocked")
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,9 @@ import (
|
||||
var AppsPluginList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+plugin-list",
|
||||
Description: "List declared plugin packages and their installation status",
|
||||
Description: "List locally installed plugin packages and their installation status",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +plugin-list",
|
||||
"Example: lark-cli apps +plugin-list --format pretty",
|
||||
@@ -40,8 +40,8 @@ func TestPluginList_Installed(t *testing.T) {
|
||||
},
|
||||
})
|
||||
manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
|
||||
os.MkdirAll(manifestDir, 0o755)
|
||||
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
@@ -99,14 +99,14 @@ func TestPluginList_DeclaredNotInstalled(t *testing.T) {
|
||||
|
||||
func chdirTest(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
prev, err := os.Getwd() //nolint:forbidigo
|
||||
prev, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil { //nolint:forbidigo
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,errcheck
|
||||
t.Cleanup(func() { os.Chdir(prev) }) //nolint:errcheck
|
||||
}
|
||||
|
||||
func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
|
||||
@@ -115,7 +115,7 @@ func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -21,6 +20,7 @@ var AppsPluginUninstall = common.Shortcut{
|
||||
Command: "+plugin-uninstall",
|
||||
Description: "Uninstall a plugin package (remove from node_modules and package.json)",
|
||||
Risk: "write",
|
||||
Scopes: []string{},
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate",
|
||||
},
|
||||
@@ -37,8 +37,10 @@ var AppsPluginUninstall = common.Shortcut{
|
||||
Set("update_file", "package.json actionPlugins")
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
if key := strings.TrimSpace(rctx.Str("name")); key == "" {
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
} else if err := validatePluginKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
@@ -58,7 +60,10 @@ var AppsPluginUninstall = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(projectPath, "node_modules", key)
|
||||
pkgDir, err := secureModulePath(projectPath, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory.
|
||||
return appsFileIOError(err, "cannot remove %s", pkgDir)
|
||||
}
|
||||
@@ -20,8 +20,8 @@ func TestPluginUninstall_Basic(t *testing.T) {
|
||||
},
|
||||
})
|
||||
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
|
||||
os.MkdirAll(pluginDir, 0o755)
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
@@ -34,7 +34,7 @@ func TestPluginUninstall_Basic(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify node_modules removed
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
|
||||
t.Error("node_modules plugin dir should be removed")
|
||||
}
|
||||
|
||||
@@ -77,12 +77,12 @@ func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) {
|
||||
})
|
||||
// Install plugin
|
||||
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
|
||||
os.MkdirAll(pluginDir, 0o755)
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
|
||||
|
||||
// Create a capability that references this plugin
|
||||
capDir := filepath.Join(dir, "server", "capabilities")
|
||||
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
|
||||
os.MkdirAll(capDir, 0o755)
|
||||
writeTestCapJSON(t, capDir, "my-instance.json", map[string]interface{}{
|
||||
"id": "my-instance",
|
||||
"pluginKey": "@test/my-plugin",
|
||||
@@ -100,7 +100,7 @@ func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify plugin directory still exists (blocked)
|
||||
if _, err := os.Stat(pluginDir); err != nil { //nolint:forbidigo
|
||||
if _, err := os.Stat(pluginDir); err != nil {
|
||||
t.Errorf("plugin directory should still exist after blocked uninstall: %v", err)
|
||||
}
|
||||
|
||||
@@ -125,12 +125,12 @@ func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) {
|
||||
},
|
||||
})
|
||||
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
|
||||
os.MkdirAll(pluginDir, 0o755)
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
|
||||
|
||||
// Create a capability that references a DIFFERENT plugin — should not block
|
||||
capDir := filepath.Join(dir, "server", "capabilities")
|
||||
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
|
||||
os.MkdirAll(capDir, 0o755)
|
||||
writeTestCapJSON(t, capDir, "other-instance.json", map[string]interface{}{
|
||||
"id": "other-instance",
|
||||
"pluginKey": "@test/other-plugin",
|
||||
@@ -148,7 +148,7 @@ func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify plugin was removed
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
|
||||
t.Error("plugin directory should be removed")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -50,13 +51,48 @@ func pluginCheckProjectDir(projectPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePluginKey validates a plugin key for use in filesystem paths.
|
||||
// Rejects empty, ".", "..", absolute paths, path traversal, and control characters.
|
||||
func validatePluginKey(key string) error {
|
||||
if key == "" || key == "." || key == ".." {
|
||||
return appsValidationError("invalid plugin key: must not be empty, \".\", or \"..\"")
|
||||
}
|
||||
if filepath.IsAbs(key) {
|
||||
return appsValidationError("invalid plugin key: must not be an absolute path: %q", key)
|
||||
}
|
||||
if strings.Contains(key, "..") {
|
||||
return appsValidationError("invalid plugin key: must not contain path traversal: %q", key)
|
||||
}
|
||||
for _, r := range key {
|
||||
if r < 32 || r == 127 {
|
||||
return appsValidationError("invalid plugin key: contains control character (code %d)", r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// secureModulePath validates the plugin key and joins it with
|
||||
// projectPath/node_modules, asserting the result stays within node_modules.
|
||||
func secureModulePath(projectPath, key string) (string, error) {
|
||||
if err := validatePluginKey(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
nodeModules := filepath.Join(projectPath, "node_modules")
|
||||
resolved := filepath.Clean(filepath.Join(nodeModules, key))
|
||||
expectedPrefix := filepath.Clean(nodeModules) + string(filepath.Separator)
|
||||
if !strings.HasPrefix(resolved+string(filepath.Separator), expectedPrefix) {
|
||||
return "", appsValidationError("plugin key %q resolves outside node_modules", key)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback:
|
||||
// 1. MIAODA_CAPABILITIES_DIR env var
|
||||
// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities)
|
||||
// 2.5 Read .env.local for MIAODA_APP_TYPE
|
||||
// 3. Detect by checking which directories exist under projectPath
|
||||
func pluginResolveCapDir(projectPath string) (string, error) {
|
||||
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional.
|
||||
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" {
|
||||
if filepath.IsAbs(dir) {
|
||||
return dir, nil
|
||||
}
|
||||
@@ -64,10 +100,16 @@ func pluginResolveCapDir(projectPath string) (string, error) {
|
||||
}
|
||||
|
||||
// 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/
|
||||
appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional.
|
||||
appType := os.Getenv("MIAODA_APP_TYPE")
|
||||
if appType == "" {
|
||||
appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE")
|
||||
}
|
||||
if appType != "" {
|
||||
if _, err := strconv.Atoi(appType); err != nil {
|
||||
return "", appsValidationError("MIAODA_APP_TYPE must be a number, got %q", appType).
|
||||
WithHint("set MIAODA_APP_TYPE to a valid numeric value in .env.local")
|
||||
}
|
||||
}
|
||||
if appType == "6" {
|
||||
return filepath.Join(projectPath, "shared", "capabilities"), nil
|
||||
}
|
||||
@@ -157,13 +199,11 @@ func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) {
|
||||
func pluginCheckDependentInstances(projectPath, pluginKey string) error {
|
||||
capDir, err := pluginResolveCapDir(projectPath)
|
||||
if err != nil {
|
||||
// No capabilities directory → no instances can exist → no conflict.
|
||||
return nil
|
||||
return nil //nolint:nilerr // best-effort: no capabilities dir means no conflict
|
||||
}
|
||||
caps, err := pluginListCapabilities(capDir)
|
||||
if err != nil {
|
||||
// Cannot scan → best-effort, don't block.
|
||||
return nil
|
||||
return nil //nolint:nilerr // best-effort: scan failure should not block uninstall
|
||||
}
|
||||
var deps []string
|
||||
for _, cap := range caps {
|
||||
@@ -181,26 +221,6 @@ func pluginCheckDependentInstances(projectPath, pluginKey string) error {
|
||||
).WithHint("delete these instances first (see <project-path>/.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall")
|
||||
}
|
||||
|
||||
// pluginCheckInstalled verifies that the plugin package is installed in node_modules
|
||||
// with a valid manifest.json.
|
||||
func pluginCheckInstalled(projectPath, pluginKey string) error {
|
||||
pluginDir := filepath.Join(projectPath, "node_modules", pluginKey)
|
||||
manifestPath := filepath.Join(pluginDir, "manifest.json")
|
||||
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check.
|
||||
if os.IsNotExist(err) {
|
||||
if pluginDirExists(pluginDir) {
|
||||
return appsFailedPreconditionError(
|
||||
"plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey,
|
||||
).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey)
|
||||
}
|
||||
return appsFailedPreconditionError("plugin %q is not installed", pluginKey).
|
||||
WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey)
|
||||
}
|
||||
return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── package.json helpers ──
|
||||
|
||||
// pluginReadPackageJSON reads and parses the project's package.json.
|
||||
@@ -324,13 +344,15 @@ func pluginInstalledVersion(projectPath, pluginKey string) string {
|
||||
|
||||
// ── tgz extraction ──
|
||||
|
||||
const pluginExtractMaxBytes = 10 * 1024 * 1024
|
||||
|
||||
// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the
|
||||
// first path component (npm convention: tarballs contain a "package/" prefix).
|
||||
// Path traversal entries are silently skipped.
|
||||
func pluginExtractTGZ(r io.Reader, destDir string) error {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip: %w", err)
|
||||
return fmt.Errorf("gzip: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
@@ -342,7 +364,7 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("tar: %w", err)
|
||||
return fmt.Errorf("tar: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed
|
||||
}
|
||||
|
||||
name := pluginStripFirstComponent(hdr.Name)
|
||||
@@ -360,6 +382,8 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
|
||||
}
|
||||
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeSymlink, tar.TypeLink:
|
||||
continue
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction.
|
||||
return err
|
||||
@@ -372,11 +396,15 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size
|
||||
f.Close()
|
||||
if _, err := io.Copy(f, io.LimitReader(tr, pluginExtractMaxBytes)); err != nil {
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
return fmt.Errorf("copy tar entry: %w; close file: %w", err, cerr) //nolint:forbidigo // intermediate helper error; callers wrap as typed
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestPluginResolveProjectPath_DefaultToCwd(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cwd, _ := os.Getwd() //nolint:forbidigo
|
||||
cwd, _ := os.Getwd()
|
||||
if got != cwd {
|
||||
t.Errorf("got %q, want cwd %q", got, cwd)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) {
|
||||
|
||||
func TestPluginCheckProjectDir_OK(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pluginCheckProjectDir(dir); err != nil {
|
||||
@@ -99,7 +99,7 @@ func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) {
|
||||
|
||||
func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo
|
||||
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
@@ -113,7 +113,7 @@ func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
|
||||
|
||||
func TestPluginResolveCapDir_DetectServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
@@ -127,7 +127,7 @@ func TestPluginResolveCapDir_DetectServer(t *testing.T) {
|
||||
|
||||
func TestPluginResolveCapDir_DetectShared(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
@@ -141,10 +141,10 @@ func TestPluginResolveCapDir_DetectShared(t *testing.T) {
|
||||
|
||||
func TestPluginResolveCapDir_Ambiguous(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := pluginResolveCapDir(dir)
|
||||
@@ -210,7 +210,7 @@ func TestPluginListCapabilities_WithFiles(t *testing.T) {
|
||||
writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"})
|
||||
writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"})
|
||||
// non-JSON file should be skipped
|
||||
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { //nolint:forbidigo
|
||||
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ func TestPluginListCapabilities_WithFiles(t *testing.T) {
|
||||
func TestPluginListCapabilities_SkipsMalformed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"})
|
||||
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { //nolint:forbidigo
|
||||
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interf
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# apps +plugin-install
|
||||
|
||||
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
|
||||
|
||||
安装插件包到项目。运行时命令事实以 `lark-cli apps +plugin-install --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# apps +plugin-list
|
||||
|
||||
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
|
||||
|
||||
列出已声明的插件包及安装状态。运行时命令事实以 `lark-cli apps +plugin-list --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# apps +plugin-uninstall
|
||||
|
||||
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
|
||||
|
||||
卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
@@ -22,7 +22,7 @@ lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run
|
||||
## 输出契约
|
||||
|
||||
- 成功读取 `data.release_id` 和 `data.status`;`release_id` 是后续 `+release-get` 的入参。
|
||||
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询。
|
||||
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min,整体超时时间大约 5min。
|
||||
- `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestAppsDBEnvCreateDryRun(t *testing.T) {
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run"},
|
||||
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -40,7 +40,7 @@ func TestAppsDBEnvCreateDryRun(t *testing.T) {
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--dry-run"},
|
||||
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestAppsDBExecuteDryRun(t *testing.T) {
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--env", "online", "--dry-run"},
|
||||
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--environment", "online", "--dry-run"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func TestAppsDBTableListDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
t.Run("DefaultsToOnlineAndPageSize20", func(t *testing.T) {
|
||||
t.Run("DefaultsToDevAndPageSize20", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestAppsDBTableListDryRun(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String())
|
||||
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
|
||||
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
|
||||
"empty page_token must be omitted")
|
||||
@@ -46,7 +46,7 @@ func TestAppsDBTableListDryRun(t *testing.T) {
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+db-table-list",
|
||||
"--app-id", "app_x", "--env", "dev",
|
||||
"--app-id", "app_x", "--environment", "dev",
|
||||
"--page-size", "50", "--page-token", "cursor-abc",
|
||||
"--dry-run"},
|
||||
DefaultAs: "user",
|
||||
|
||||
Reference in New Issue
Block a user