mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
42 Commits
fix/plugin
...
feat/apps-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412dcba4b8 | ||
|
|
e5f66ce22e | ||
|
|
1d313a56b1 | ||
|
|
1864b7fae9 | ||
|
|
22ae7ab04d | ||
|
|
110107458a | ||
|
|
e28a00c2fe | ||
|
|
2f50e39203 | ||
|
|
b5d3e9896e | ||
|
|
a552aed3bc | ||
|
|
70aec2726b | ||
|
|
52894d095b | ||
|
|
7810a01eba | ||
|
|
b33fe32718 | ||
|
|
490006ee7b | ||
|
|
4e2abab504 | ||
|
|
0ff2957c6e | ||
|
|
41aefd63f0 | ||
|
|
09984fa92a | ||
|
|
de5de57ced | ||
|
|
911f584ab0 | ||
|
|
08340bf3aa | ||
|
|
a99dc33195 | ||
|
|
bb891e0c50 | ||
|
|
d5f65d1aa4 | ||
|
|
5365cb97ab | ||
|
|
8037bd8037 | ||
|
|
dbc1c93b71 | ||
|
|
2beb110523 | ||
|
|
112183f447 | ||
|
|
3b9ee1af67 | ||
|
|
a5386f6053 | ||
|
|
d6c37232e6 | ||
|
|
999ac4e7d6 | ||
|
|
a91f2cdd85 | ||
|
|
d80636d7da | ||
|
|
0a999171be | ||
|
|
1d9f102b36 | ||
|
|
d7820f7c1f | ||
|
|
b8f45c96d7 | ||
|
|
9dc032ca73 | ||
|
|
e9f2da086f |
392
shortcuts/apps/plugin_common.go
Normal file
392
shortcuts/apps/plugin_common.go
Normal file
@@ -0,0 +1,392 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// pluginResolveProjectPath resolves --project-path to an absolute path,
|
||||
// defaulting to cwd when empty.
|
||||
func pluginResolveProjectPath(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err)
|
||||
}
|
||||
return cwd, nil
|
||||
}
|
||||
if err := validate.RejectControlChars(raw, "--project-path"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(raw), nil
|
||||
}
|
||||
|
||||
// pluginCheckProjectDir validates that projectPath contains a package.json.
|
||||
func pluginCheckProjectDir(projectPath string) error {
|
||||
info, err := os.Stat(filepath.Join(projectPath, "package.json")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for project dir check.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return appsFailedPreconditionError("package.json not found in %s", projectPath).
|
||||
WithHint("run 'lark-cli apps +init' to initialize the project first")
|
||||
}
|
||||
return appsFileIOError(err, "cannot access package.json in %s", projectPath)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return appsFailedPreconditionError("package.json in %s is not a regular file", projectPath)
|
||||
}
|
||||
return 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 filepath.IsAbs(dir) {
|
||||
return dir, nil
|
||||
}
|
||||
return filepath.Join(projectPath, dir), nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
if appType == "" {
|
||||
appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE")
|
||||
}
|
||||
if appType == "6" {
|
||||
return filepath.Join(projectPath, "shared", "capabilities"), nil
|
||||
}
|
||||
if appType != "" {
|
||||
return filepath.Join(projectPath, "server", "capabilities"), nil
|
||||
}
|
||||
|
||||
// 3. Directory detection
|
||||
serverDir := filepath.Join(projectPath, "server", "capabilities")
|
||||
sharedDir := filepath.Join(projectPath, "shared", "capabilities")
|
||||
serverOK := pluginDirExists(serverDir)
|
||||
sharedOK := pluginDirExists(sharedDir)
|
||||
|
||||
switch {
|
||||
case serverOK && sharedOK:
|
||||
return "", appsFailedPreconditionError(
|
||||
"ambiguous capabilities path: both server/capabilities/ and shared/capabilities/ exist",
|
||||
).WithHint("set MIAODA_APP_TYPE or MIAODA_CAPABILITIES_DIR in .env.local to resolve ambiguity")
|
||||
case serverOK:
|
||||
return serverDir, nil
|
||||
case sharedOK:
|
||||
return sharedDir, nil
|
||||
default:
|
||||
return filepath.Join(projectPath, "server", "capabilities"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// pluginReadEnvLocalValue reads a value from .env.local by key name.
|
||||
func pluginReadEnvLocalValue(projectPath, key string) string {
|
||||
data, err := os.ReadFile(filepath.Join(projectPath, ".env.local")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local env file read.
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok || strings.TrimSpace(k) != key {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.Trim(v, "\"'")
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pluginDirExists(path string) bool {
|
||||
info, err := os.Stat(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir existence check.
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// pluginListCapabilities reads all *.json files from capDir.
|
||||
// Returns nil (not error) if the directory does not exist.
|
||||
func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) {
|
||||
entries, err := os.ReadDir(capDir) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir listing.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, appsFileIOError(err, "cannot read capabilities directory %s", capDir)
|
||||
}
|
||||
|
||||
var caps []map[string]interface{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(capDir, entry.Name())) //nolint:forbidigo
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cap map[string]interface{}
|
||||
if err := json.Unmarshal(data, &cap); err != nil {
|
||||
continue
|
||||
}
|
||||
caps = append(caps, cap)
|
||||
}
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
// pluginCheckDependentInstances scans the capabilities directory for instances
|
||||
// that reference the given pluginKey. Returns nil if none found, an error with
|
||||
// the list of dependent instance ids if any exist, or the underlying I/O 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
|
||||
}
|
||||
caps, err := pluginListCapabilities(capDir)
|
||||
if err != nil {
|
||||
// Cannot scan → best-effort, don't block.
|
||||
return nil
|
||||
}
|
||||
var deps []string
|
||||
for _, cap := range caps {
|
||||
if pk, _ := cap["pluginKey"].(string); pk == pluginKey {
|
||||
if id, _ := cap["id"].(string); id != "" {
|
||||
deps = append(deps, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(deps) == 0 {
|
||||
return nil
|
||||
}
|
||||
return appsFailedPreconditionError(
|
||||
"plugin %q is still referenced by %d instance(s): %s", pluginKey, len(deps), strings.Join(deps, ", "),
|
||||
).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.
|
||||
func pluginReadPackageJSON(projectPath string) (map[string]interface{}, error) {
|
||||
path := filepath.Join(projectPath, "package.json")
|
||||
data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package.json read.
|
||||
if err != nil {
|
||||
return nil, appsFileIOError(err, "cannot read package.json")
|
||||
}
|
||||
var pkg map[string]interface{}
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return nil, appsValidationError("invalid package.json: %v", err).WithCause(err)
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
// pluginWritePackageJSON writes package.json atomically, preserving formatting.
|
||||
func pluginWritePackageJSON(projectPath string, pkg map[string]interface{}) error {
|
||||
data, err := json.MarshalIndent(pkg, "", " ")
|
||||
if err != nil {
|
||||
return appsFileIOError(err, "cannot marshal package.json")
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return validate.AtomicWrite(filepath.Join(projectPath, "package.json"), data, 0o644)
|
||||
}
|
||||
|
||||
// pluginGetActionPlugins extracts actionPlugins from package.json as key→version.
|
||||
func pluginGetActionPlugins(pkg map[string]interface{}) map[string]string {
|
||||
raw, ok := pkg["actionPlugins"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
if s, ok := v.(string); ok {
|
||||
out[k] = s
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pluginSetActionPlugin adds or updates a plugin entry in actionPlugins.
|
||||
func pluginSetActionPlugin(pkg map[string]interface{}, key, version string) {
|
||||
m, ok := pkg["actionPlugins"].(map[string]interface{})
|
||||
if !ok {
|
||||
m = make(map[string]interface{})
|
||||
pkg["actionPlugins"] = m
|
||||
}
|
||||
m[key] = version
|
||||
}
|
||||
|
||||
// pluginRemoveActionPlugin removes a plugin entry from actionPlugins.
|
||||
func pluginRemoveActionPlugin(pkg map[string]interface{}, key string) {
|
||||
m, ok := pkg["actionPlugins"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(m, key)
|
||||
}
|
||||
|
||||
// pluginSyncActionPlugins ensures the actionPlugins record in package.json
|
||||
// matches the actually installed version, even when install is skipped.
|
||||
func pluginSyncActionPlugins(projectPath, key, version string) {
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ap := pluginGetActionPlugins(pkg)
|
||||
if ap[key] == version {
|
||||
return
|
||||
}
|
||||
pluginSetActionPlugin(pkg, key, version)
|
||||
_ = pluginWritePackageJSON(projectPath, pkg)
|
||||
}
|
||||
|
||||
// pluginCheckPeerDeps reads peerDependencies from the installed plugin's
|
||||
// package.json and returns the names of any that are missing from node_modules.
|
||||
func pluginCheckPeerDeps(projectPath, pluginKey string) []string {
|
||||
pkgPath := filepath.Join(projectPath, "node_modules", pluginKey, "package.json")
|
||||
data, err := os.ReadFile(pkgPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read.
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var pkg map[string]interface{}
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return nil
|
||||
}
|
||||
peerDeps, ok := pkg["peerDependencies"].(map[string]interface{})
|
||||
if !ok || len(peerDeps) == 0 {
|
||||
return nil
|
||||
}
|
||||
var missing []string
|
||||
for dep := range peerDeps {
|
||||
depDir := filepath.Join(projectPath, "node_modules", dep)
|
||||
if !pluginDirExists(depDir) {
|
||||
missing = append(missing, dep)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// pluginInstalledVersion reads the version of an installed plugin from its
|
||||
// package.json in node_modules. Returns "" if not found or unreadable.
|
||||
func pluginInstalledVersion(projectPath, pluginKey string) string {
|
||||
path := filepath.Join(projectPath, "node_modules", pluginKey, "package.json")
|
||||
data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read.
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var pkg map[string]interface{}
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := pkg["version"].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// ── tgz extraction ──
|
||||
|
||||
// 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)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
cleanDest := filepath.Clean(destDir) + string(filepath.Separator)
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("tar: %w", err)
|
||||
}
|
||||
|
||||
name := pluginStripFirstComponent(hdr.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
continue
|
||||
}
|
||||
|
||||
target := filepath.Join(destDir, name)
|
||||
if !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), cleanDest) &&
|
||||
filepath.Clean(target) != filepath.Clean(destDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction.
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { //nolint:forbidigo
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o755) //nolint:forbidigo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginStripFirstComponent removes the first path component ("package/foo" → "foo").
|
||||
func pluginStripFirstComponent(name string) string {
|
||||
name = filepath.ToSlash(name)
|
||||
if i := strings.Index(name, "/"); i >= 0 {
|
||||
return name[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
253
shortcuts/apps/plugin_common_test.go
Normal file
253
shortcuts/apps/plugin_common_test.go
Normal file
@@ -0,0 +1,253 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// --- pluginResolveProjectPath ---
|
||||
|
||||
func TestPluginResolveProjectPath_DefaultToCwd(t *testing.T) {
|
||||
got, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cwd, _ := os.Getwd() //nolint:forbidigo
|
||||
if got != cwd {
|
||||
t.Errorf("got %q, want cwd %q", got, cwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) {
|
||||
got, err := pluginResolveProjectPath("/tmp/myapp")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "/tmp/myapp" {
|
||||
t.Errorf("got %q, want /tmp/myapp", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pluginCheckProjectDir ---
|
||||
|
||||
func TestPluginCheckProjectDir_OK(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pluginCheckProjectDir(dir); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCheckProjectDir_Missing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := pluginCheckProjectDir(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", p.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pluginResolveCapDir ---
|
||||
|
||||
func TestPluginResolveCapDir_EnvVar(t *testing.T) {
|
||||
t.Setenv("MIAODA_CAPABILITIES_DIR", "envdir/caps")
|
||||
got, err := pluginResolveCapDir("/proj")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join("/proj", "envdir/caps"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_AppTypeEnv(t *testing.T) {
|
||||
t.Setenv("MIAODA_APP_TYPE", "2")
|
||||
got, err := pluginResolveCapDir("/proj")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join("/proj", "server", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) {
|
||||
t.Setenv("MIAODA_APP_TYPE", "6")
|
||||
got, err := pluginResolveCapDir("/proj")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join("/proj", "shared", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join(dir, "server", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_DetectServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join(dir, "server", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_DetectShared(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join(dir, "shared", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_Ambiguous(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := pluginResolveCapDir(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", p.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_NeitherExists_DefaultsToServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("should default to server/capabilities, got error: %v", err)
|
||||
}
|
||||
if want := filepath.Join(dir, "server", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_AppType3_UsesServer(t *testing.T) {
|
||||
t.Setenv("MIAODA_APP_TYPE", "3")
|
||||
got, err := pluginResolveCapDir("/proj")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join("/proj", "server", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q (appType=3 should use server)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pluginListCapabilities ---
|
||||
|
||||
func TestPluginListCapabilities_Empty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caps, err := pluginListCapabilities(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(caps) != 0 {
|
||||
t.Errorf("got %d caps, want 0", len(caps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginListCapabilities_DirNotExist(t *testing.T) {
|
||||
caps, err := pluginListCapabilities("/nonexistent/path")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if caps != nil {
|
||||
t.Errorf("got %v, want nil", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginListCapabilities_WithFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
caps, err := pluginListCapabilities(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(caps) != 2 {
|
||||
t.Fatalf("got %d caps, want 2", len(caps))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
caps, err := pluginListCapabilities(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("got %d caps, want 1", len(caps))
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interface{}) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
393
shortcuts/apps/plugin_install.go
Normal file
393
shortcuts/apps/plugin_install.go
Normal file
@@ -0,0 +1,393 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsPluginInstall downloads a plugin package from the registry, extracts it
|
||||
// to node_modules, and updates package.json actionPlugins.
|
||||
//
|
||||
// Without --name it batch-installs all plugins declared in actionPlugins that
|
||||
// are not yet present in node_modules.
|
||||
var AppsPluginInstall = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+plugin-install",
|
||||
Description: "Install a plugin package (download, extract, update package.json)",
|
||||
Risk: "write",
|
||||
ConditionalScopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0",
|
||||
"Example: lark-cli apps +plugin-install (install all declared plugins in package.json)",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"},
|
||||
{Name: "version", Desc: "plugin version (e.g. 1.0.0); omit to install latest"},
|
||||
{Name: "file", Desc: "install from a local .tgz file (dev/test only)", Hidden: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
key := strings.TrimSpace(rctx.Str("name"))
|
||||
if key == "" {
|
||||
return common.NewDryRunAPI().
|
||||
POST(apiBasePath+"/plugin/versions/batch_query").
|
||||
Desc("Batch-install all declared plugins from package.json actionPlugins").
|
||||
Set("request_body", `{"plugin_keys": [<from actionPlugins>], "latest_only": false}`)
|
||||
}
|
||||
version := strings.TrimSpace(rctx.Str("version"))
|
||||
isLatest := version == "" || version == "latest"
|
||||
desc := fmt.Sprintf("Query version for %s, then download .tgz", key)
|
||||
if isLatest {
|
||||
desc = fmt.Sprintf("Install latest version of %s (omit --version to install latest)", key)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(apiBasePath+"/plugin/versions/batch_query").
|
||||
Desc(desc).
|
||||
Set("request_body", fmt.Sprintf(`{"plugin_keys": ["%s"], "latest_only": %v}`, key, isLatest)).
|
||||
Set("download_body", fmt.Sprintf(`{"plugin_key": "%s", "plugin_version": "%s"}`, key, version))
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pluginCheckProjectDir(projectPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if localTgz := strings.TrimSpace(rctx.Str("file")); localTgz != "" {
|
||||
return pluginInstallLocal(rctx, projectPath, localTgz)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(rctx.Str("name"))
|
||||
if key == "" {
|
||||
return pluginInstallAll(ctx, rctx, projectPath)
|
||||
}
|
||||
version := strings.TrimSpace(rctx.Str("version"))
|
||||
return pluginInstallOne(ctx, rctx, projectPath, key, version)
|
||||
},
|
||||
}
|
||||
|
||||
// pluginInstallOne installs a single plugin by key and optional version.
|
||||
func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectPath, key, version string) error {
|
||||
if key == "" {
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
}
|
||||
|
||||
// Check if already installed with same version (pre-API fast path)
|
||||
if version != "" && version != "latest" {
|
||||
if installed := pluginInstalledVersion(projectPath, key); installed == version {
|
||||
pluginSyncActionPlugins(projectPath, key, version)
|
||||
result := map[string]interface{}{
|
||||
"key": key, "version": version, "status": "already_installed",
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ %s@%s is already installed\n", key, version)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve version via API
|
||||
resolvedVersion, err := pluginResolveVersion(ctx, rctx, key, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Post-API check: latest may resolve to the already-installed version
|
||||
if installed := pluginInstalledVersion(projectPath, key); installed == resolvedVersion {
|
||||
pluginSyncActionPlugins(projectPath, key, resolvedVersion)
|
||||
result := map[string]interface{}{
|
||||
"key": key, "version": resolvedVersion, "status": "already_installed",
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ %s@%s is already up to date\n", key, resolvedVersion)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download tgz
|
||||
tgzData, err := pluginDownloadPackage(ctx, rctx, key, resolvedVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract to node_modules
|
||||
destDir := filepath.Join(projectPath, "node_modules", key)
|
||||
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract.
|
||||
return appsFileIOError(err, "cannot clean %s", destDir)
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil { //nolint:forbidigo
|
||||
return appsFileIOError(err, "cannot create %s", destDir)
|
||||
}
|
||||
if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil {
|
||||
return appsFileIOError(err, "cannot extract plugin package for %s", key)
|
||||
}
|
||||
|
||||
// Check peer dependencies
|
||||
missingPeers := pluginCheckPeerDeps(projectPath, key)
|
||||
|
||||
// Update package.json
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pluginSetActionPlugin(pkg, key, resolvedVersion)
|
||||
if err := pluginWritePackageJSON(projectPath, pkg); err != nil {
|
||||
return appsFileIOError(err, "cannot update package.json")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"key": key, "version": resolvedVersion, "status": "installed",
|
||||
}
|
||||
if len(missingPeers) > 0 {
|
||||
result["missing_peer_dependencies"] = missingPeers
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Installed %s@%s\n", key, resolvedVersion)
|
||||
if len(missingPeers) > 0 {
|
||||
fmt.Fprintf(w, "⚠ Missing peer dependencies: %s\n", strings.Join(missingPeers, ", "))
|
||||
fmt.Fprintln(w, " Run 'npm install' in the project directory to install them.")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginInstallAll installs all plugins declared in actionPlugins that are
|
||||
// missing from node_modules.
|
||||
func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectPath string) error {
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
declared := pluginGetActionPlugins(pkg)
|
||||
if len(declared) == 0 {
|
||||
rctx.OutFormat(map[string]interface{}{"installed": 0}, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var installed int
|
||||
for key, version := range declared {
|
||||
existing := pluginInstalledVersion(projectPath, key)
|
||||
if existing != "" && existing == version {
|
||||
continue
|
||||
}
|
||||
if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil {
|
||||
return fmt.Errorf("install %s: %w", key, err)
|
||||
}
|
||||
installed++
|
||||
}
|
||||
|
||||
if installed == 0 {
|
||||
rctx.OutFormat(map[string]interface{}{"installed": 0, "status": "all_up_to_date"}, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "All declared plugins are already installed.")
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginInstallLocal installs a plugin from a local .tgz file, skipping API calls.
|
||||
// Reads plugin key and version from the extracted package.json inside the tgz.
|
||||
func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string) error {
|
||||
tgzData, err := os.ReadFile(tgzPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local tgz read.
|
||||
if err != nil {
|
||||
return appsValidationParamError("--file", "cannot read tgz file %s: %v", tgzPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
// Extract to a temp dir first to read package.json
|
||||
tmpDir, err := os.MkdirTemp("", "plugin-local-*") //nolint:forbidigo
|
||||
if err != nil {
|
||||
return appsFileIOError(err, "cannot create temp dir")
|
||||
}
|
||||
defer os.RemoveAll(tmpDir) //nolint:forbidigo
|
||||
|
||||
if err := pluginExtractTGZ(bytes.NewReader(tgzData), tmpDir); err != nil {
|
||||
return appsFileIOError(err, "cannot extract tgz")
|
||||
}
|
||||
|
||||
// Read key and version from extracted package.json
|
||||
pkgData, err := os.ReadFile(filepath.Join(tmpDir, "package.json")) //nolint:forbidigo
|
||||
if err != nil {
|
||||
return appsFileIOError(err, "tgz does not contain package.json")
|
||||
}
|
||||
var pkgMeta map[string]interface{}
|
||||
if err := json.Unmarshal(pkgData, &pkgMeta); err != nil {
|
||||
return appsFileIOError(err, "invalid package.json in tgz")
|
||||
}
|
||||
key, _ := pkgMeta["name"].(string)
|
||||
version, _ := pkgMeta["version"].(string)
|
||||
if key == "" {
|
||||
return appsValidationParamError("--file", "package.json in tgz missing 'name' field")
|
||||
}
|
||||
if version == "" {
|
||||
version = "0.0.0"
|
||||
}
|
||||
|
||||
// Move to node_modules
|
||||
destDir := filepath.Join(projectPath, "node_modules", key)
|
||||
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo
|
||||
return appsFileIOError(err, "cannot clean %s", destDir)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil { //nolint:forbidigo
|
||||
return appsFileIOError(err, "cannot create parent dir for %s", destDir)
|
||||
}
|
||||
if err := os.Rename(tmpDir, destDir); err != nil { //nolint:forbidigo
|
||||
// rename may fail across filesystems; fall back to re-extract
|
||||
if err2 := os.MkdirAll(destDir, 0o755); err2 != nil { //nolint:forbidigo
|
||||
return appsFileIOError(err2, "cannot create %s", destDir)
|
||||
}
|
||||
if err2 := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err2 != nil {
|
||||
return appsFileIOError(err2, "cannot extract plugin to %s", destDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Update package.json actionPlugins
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pluginSetActionPlugin(pkg, key, version)
|
||||
if err := pluginWritePackageJSON(projectPath, pkg); err != nil {
|
||||
return appsFileIOError(err, "cannot update package.json")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"key": key, "version": version, "status": "installed", "source": "local",
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Installed %s@%s (from local %s)\n", key, version, tgzPath)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginResolveVersion calls the batch_query API to resolve version info.
|
||||
func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, version string) (resolvedVersion string, err error) {
|
||||
isLatest := version == "" || version == "latest"
|
||||
body := map[string]interface{}{
|
||||
"plugin_keys": []interface{}{key},
|
||||
"latest_only": isLatest,
|
||||
}
|
||||
|
||||
data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugin/versions/batch_query", nil, body)
|
||||
if err != nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if ok && p.Subtype == errs.SubtypeInvalidResponse {
|
||||
p.Message = fmt.Sprintf("plugin registry API is not available (returned non-JSON for %s)", key)
|
||||
p.Hint = "the plugin registry endpoint may not be registered yet; check with the backend team"
|
||||
return "", err
|
||||
}
|
||||
return "", withAppsHint(err, fmt.Sprintf("failed to fetch plugin version for %s; check plugin key spelling and network", key))
|
||||
}
|
||||
|
||||
// Response: data.items is a flat list of plugin_version objects
|
||||
match := pluginFindVersionInItems(data, key, version)
|
||||
if match == nil {
|
||||
hint := "check plugin key spelling"
|
||||
if !isLatest {
|
||||
hint = fmt.Sprintf("version %q not found for %s; omit --version to install latest", version, key)
|
||||
}
|
||||
return "", appsValidationError("no version found for plugin %q", key).
|
||||
WithHint(hint)
|
||||
}
|
||||
// API returns "version" (not "plugin_version")
|
||||
rv, _ := match["version"].(string)
|
||||
if rv == "" {
|
||||
return "", appsValidationError("incomplete version info for plugin %q", key).
|
||||
WithHint("API returned version info without version field; contact plugin maintainer")
|
||||
}
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
// pluginFindVersionInItems extracts data.items and finds a matching version.
|
||||
func pluginFindVersionInItems(data map[string]interface{}, key, version string) map[string]interface{} {
|
||||
raw, ok := data["items"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
arr, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
isLatest := version == "" || version == "latest"
|
||||
for _, v := range arr {
|
||||
item, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// API returns "key" (not "plugin_key")
|
||||
pk, _ := item["key"].(string)
|
||||
if pk != key {
|
||||
continue
|
||||
}
|
||||
if isLatest {
|
||||
return item
|
||||
}
|
||||
pv, _ := item["version"].(string)
|
||||
if pv == version {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginDownloadPackage downloads a plugin .tgz via the download_package API.
|
||||
// The endpoint is POST with JSON body {plugin_key, plugin_version}.
|
||||
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{
|
||||
"plugin_key": key,
|
||||
"plugin_version": version,
|
||||
})
|
||||
|
||||
resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: apiPath,
|
||||
Body: bytes.NewReader(body),
|
||||
})
|
||||
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)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed for %s@%s: HTTP %d", key, version, resp.StatusCode).
|
||||
WithHint("plugin registry returned a server error; retry after a short wait").
|
||||
WithRetryable()
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
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"
|
||||
} else if resp.StatusCode == 404 {
|
||||
hint = fmt.Sprintf("package %s@%s not found in registry; check plugin key and version", key, version)
|
||||
}
|
||||
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)
|
||||
}
|
||||
181
shortcuts/apps/plugin_install_test.go
Normal file
181
shortcuts/apps/plugin_install_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestPluginInstall_SinglePlugin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
// Mock batch_query API (new protocol: plugin_keys array, response data.items flat list)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/plugin/versions/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"key": "@test/my-plugin",
|
||||
"version": "1.0.0",
|
||||
"download_approach": "inner",
|
||||
"status": "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock download API (POST with JSON body, returns binary tgz)
|
||||
tgzData := buildTestTGZ(t, map[string]string{
|
||||
"manifest.json": `{"actions":[]}`,
|
||||
"package.json": `{"name":"@test/my-plugin","version":"1.0.0"}`,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/plugin/versions/download_package",
|
||||
RawBody: tgzData,
|
||||
ContentType: "application/octet-stream",
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsPluginInstall, []string{
|
||||
"+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify file extracted
|
||||
manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json")
|
||||
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo
|
||||
t.Fatalf("manifest.json not extracted: %v", err)
|
||||
}
|
||||
|
||||
// Verify package.json updated
|
||||
pkg, _ := pluginReadPackageJSON(dir)
|
||||
ap := pluginGetActionPlugins(pkg)
|
||||
if v := ap["@test/my-plugin"]; v != "1.0.0" {
|
||||
t.Errorf("actionPlugins[@test/my-plugin] = %q, want 1.0.0", v)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if data["status"] != "installed" {
|
||||
t.Errorf("status = %v, want installed", data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginInstall_AlreadyInstalled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/my-plugin": "1.0.0",
|
||||
},
|
||||
})
|
||||
// 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
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginInstall, []string{
|
||||
"+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if data["status"] != "already_installed" {
|
||||
t.Errorf("status = %v, want already_installed", data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- tgz helpers ---
|
||||
|
||||
func TestPluginExtractTGZ(t *testing.T) {
|
||||
tgzData := buildTestTGZ(t, map[string]string{
|
||||
"manifest.json": `{"actions":[]}`,
|
||||
"README.md": "# Hello",
|
||||
})
|
||||
|
||||
destDir := t.TempDir()
|
||||
if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil {
|
||||
t.Fatalf("extract error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo
|
||||
if err != nil {
|
||||
t.Fatalf("manifest.json not extracted: %v", err)
|
||||
}
|
||||
if string(data) != `{"actions":[]}` {
|
||||
t.Errorf("manifest.json content = %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginExtractTGZ_PathTraversal(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
tw.WriteHeader(&tar.Header{
|
||||
Name: "package/../../../etc/passwd",
|
||||
Size: 5,
|
||||
Mode: 0o644,
|
||||
Typeflag: tar.TypeReg,
|
||||
})
|
||||
tw.Write([]byte("evil!"))
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
|
||||
destDir := t.TempDir()
|
||||
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
|
||||
t.Error("path traversal should have been blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// buildTestTGZ creates a .tgz in memory with files under a "package/" prefix.
|
||||
func buildTestTGZ(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
for name, content := range files {
|
||||
tw.WriteHeader(&tar.Header{
|
||||
Name: "package/" + name,
|
||||
Size: int64(len(content)),
|
||||
Mode: 0o644,
|
||||
Typeflag: tar.TypeReg,
|
||||
})
|
||||
tw.Write([]byte(content))
|
||||
}
|
||||
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
80
shortcuts/apps/plugin_list.go
Normal file
80
shortcuts/apps/plugin_list.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsPluginList lists plugin packages declared in package.json actionPlugins,
|
||||
// cross-referencing with node_modules to report installation status.
|
||||
var AppsPluginList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+plugin-list",
|
||||
Description: "List declared plugin packages and their installation status",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +plugin-list",
|
||||
"Example: lark-cli apps +plugin-list --format pretty",
|
||||
},
|
||||
Flags: []common.Flag{},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List declared plugin packages and installation status").
|
||||
Set("action", "list").
|
||||
Set("source", "package.json actionPlugins + node_modules")
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pluginCheckProjectDir(projectPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
declared := pluginGetActionPlugins(pkg)
|
||||
plugins := make([]interface{}, 0, len(declared))
|
||||
for key, version := range declared {
|
||||
installed := pluginInstalledVersion(projectPath, key)
|
||||
status := "declared_not_installed"
|
||||
if installed != "" {
|
||||
status = "installed"
|
||||
}
|
||||
plugins = append(plugins, map[string]interface{}{
|
||||
"key": key,
|
||||
"version": version,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
data := map[string]interface{}{"plugins": plugins}
|
||||
rctx.OutFormat(data, &output.Meta{Count: len(plugins)}, func(w io.Writer) {
|
||||
if len(plugins) == 0 {
|
||||
fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.")
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]interface{}, 0, len(plugins))
|
||||
for _, p := range plugins {
|
||||
rows = append(rows, p.(map[string]interface{}))
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
121
shortcuts/apps/plugin_list_test.go
Normal file
121
shortcuts/apps/plugin_list_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginList_Empty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginList, []string{
|
||||
"+plugin-list", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
plugins, _ := data["plugins"].([]interface{})
|
||||
if len(plugins) != 0 {
|
||||
t.Errorf("expected 0 plugins, got %d", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginList_Installed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/my-plugin": "1.0.0",
|
||||
},
|
||||
})
|
||||
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
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginList, []string{
|
||||
"+plugin-list", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
plugins, _ := data["plugins"].([]interface{})
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("expected 1 plugin, got %d", len(plugins))
|
||||
}
|
||||
p := plugins[0].(map[string]interface{})
|
||||
if p["status"] != "installed" {
|
||||
t.Errorf("status = %v, want installed", p["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginList_DeclaredNotInstalled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/missing": "1.0.0",
|
||||
},
|
||||
})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginList, []string{
|
||||
"+plugin-list", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
plugins, _ := data["plugins"].([]interface{})
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("expected 1 plugin, got %d", len(plugins))
|
||||
}
|
||||
p := plugins[0].(map[string]interface{})
|
||||
if p["status"] != "declared_not_installed" {
|
||||
t.Errorf("status = %v, want declared_not_installed", p["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func chdirTest(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
prev, err := os.Getwd() //nolint:forbidigo
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,errcheck
|
||||
}
|
||||
|
||||
func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(pkg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
84
shortcuts/apps/plugin_uninstall.go
Normal file
84
shortcuts/apps/plugin_uninstall.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsPluginUninstall removes a plugin package from node_modules and its
|
||||
// entry from package.json actionPlugins.
|
||||
var AppsPluginUninstall = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+plugin-uninstall",
|
||||
Description: "Uninstall a plugin package (remove from node_modules and package.json)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate)", Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
key := strings.TrimSpace(rctx.Str("name"))
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Uninstall plugin package (remove from node_modules and package.json)").
|
||||
Set("action", "uninstall").
|
||||
Set("plugin_key", key).
|
||||
Set("remove_dir", fmt.Sprintf("node_modules/%s", key)).
|
||||
Set("update_file", "package.json actionPlugins")
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
}
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pluginCheckProjectDir(projectPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
key := strings.TrimSpace(rctx.Str("name"))
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Block uninstall if any instances still reference this plugin package.
|
||||
if err := pluginCheckDependentInstances(projectPath, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(projectPath, "node_modules", key)
|
||||
if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory.
|
||||
return appsFileIOError(err, "cannot remove %s", pkgDir)
|
||||
}
|
||||
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pluginRemoveActionPlugin(pkg, key)
|
||||
if err := pluginWritePackageJSON(projectPath, pkg); err != nil {
|
||||
return appsFileIOError(err, "cannot update package.json")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"key": key,
|
||||
"removed": true,
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Plugin uninstalled: %s\n", key)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
187
shortcuts/apps/plugin_uninstall_test.go
Normal file
187
shortcuts/apps/plugin_uninstall_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestPluginUninstall_Basic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/my-plugin": "1.0.0",
|
||||
},
|
||||
})
|
||||
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
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginUninstall, []string{
|
||||
"+plugin-uninstall", "--name", "@test/my-plugin",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify node_modules removed
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
|
||||
t.Error("node_modules plugin dir should be removed")
|
||||
}
|
||||
|
||||
// Verify package.json updated
|
||||
pkg, _ := pluginReadPackageJSON(dir)
|
||||
ap := pluginGetActionPlugins(pkg)
|
||||
if _, ok := ap["@test/my-plugin"]; ok {
|
||||
t.Error("actionPlugins should no longer contain @test/my-plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginUninstall_NotInstalled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginUninstall, []string{
|
||||
"+plugin-uninstall", "--name", "@test/not-here",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("uninstalling non-existent plugin should succeed: %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if data["removed"] != true {
|
||||
t.Errorf("removed = %v, want true", data["removed"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/my-plugin": "1.0.0",
|
||||
},
|
||||
})
|
||||
// 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
|
||||
|
||||
// Create a capability that references this plugin
|
||||
capDir := filepath.Join(dir, "server", "capabilities")
|
||||
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
|
||||
writeTestCapJSON(t, capDir, "my-instance.json", map[string]interface{}{
|
||||
"id": "my-instance",
|
||||
"pluginKey": "@test/my-plugin",
|
||||
"name": "My Instance",
|
||||
})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginUninstall, []string{
|
||||
"+plugin-uninstall", "--name", "@test/my-plugin",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when uninstalling a plugin with dependent instances, got nil")
|
||||
}
|
||||
|
||||
// Verify plugin directory still exists (blocked)
|
||||
if _, err := os.Stat(pluginDir); err != nil { //nolint:forbidigo
|
||||
t.Errorf("plugin directory should still exist after blocked uninstall: %v", err)
|
||||
}
|
||||
|
||||
// Verify error mentions the dependent instance
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed error, got %v", err)
|
||||
}
|
||||
if prob.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", prob.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if prob.Hint == "" {
|
||||
t.Error("hint should be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/my-plugin": "1.0.0",
|
||||
},
|
||||
})
|
||||
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
|
||||
|
||||
// Create a capability that references a DIFFERENT plugin — should not block
|
||||
capDir := filepath.Join(dir, "server", "capabilities")
|
||||
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
|
||||
writeTestCapJSON(t, capDir, "other-instance.json", map[string]interface{}{
|
||||
"id": "other-instance",
|
||||
"pluginKey": "@test/other-plugin",
|
||||
"name": "Other Instance",
|
||||
})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginUninstall, []string{
|
||||
"+plugin-uninstall", "--name", "@test/my-plugin",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("uninstall should succeed when instances reference different plugins: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin was removed
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
|
||||
t.Error("plugin directory should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginUninstall_PreservesOtherPlugins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"name": "my-app",
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/remove-me": "1.0.0",
|
||||
"@test/keep-me": "2.0.0",
|
||||
},
|
||||
})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginUninstall, []string{
|
||||
"+plugin-uninstall", "--name", "@test/remove-me",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
pkg, _ := pluginReadPackageJSON(dir)
|
||||
ap := pluginGetActionPlugins(pkg)
|
||||
if _, ok := ap["@test/remove-me"]; ok {
|
||||
t.Error("@test/remove-me should be removed from actionPlugins")
|
||||
}
|
||||
if v, ok := ap["@test/keep-me"]; !ok || v != "2.0.0" {
|
||||
t.Errorf("@test/keep-me should be preserved, got %q", v)
|
||||
}
|
||||
if name, _ := pkg["name"].(string); name != "my-app" {
|
||||
t.Errorf("other fields should be preserved, name = %q", name)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsSessionStop,
|
||||
AppsSessionMessagesList,
|
||||
AppsChat,
|
||||
AppsPluginInstall,
|
||||
AppsPluginUninstall,
|
||||
AppsPluginList,
|
||||
// open API key management
|
||||
AppsOpenAPIKeyList,
|
||||
AppsOpenAPIKeyGet,
|
||||
|
||||
@@ -19,11 +19,12 @@ import (
|
||||
// - 7 file(list/get/sign/download/upload/delete/quota-get)
|
||||
// - 3 git-credential
|
||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)= 60。
|
||||
func TestAppsShortcuts_Returns60(t *testing.T) {
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||
// - 3 plugin(install/uninstall/list)= 63。
|
||||
func TestAppsShortcuts_Returns63(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 60 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 60", len(got))
|
||||
if len(got) != 63 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 63", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-apps
|
||||
version: 1.0.0
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -22,7 +22,7 @@ metadata:
|
||||
| 找已有 app_id、按名字过滤应用 | `+list --keyword <name>` | [`lark-apps-list.md`](references/lark-apps-list.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) |
|
||||
| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 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) |
|
||||
| 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) |
|
||||
| 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) |
|
||||
| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) |
|
||||
@@ -34,6 +34,7 @@ metadata:
|
||||
| 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
|
||||
| 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) |
|
||||
| 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id <id>`(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) |
|
||||
| 外部能力(AI模型能力和飞书平台能力)集成/插件/Plugin/Capability | `+plugin-install`, `+plugin-list`, `+plugin-uninstall` | [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md), [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md), [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) |
|
||||
|
||||
## 高频路径
|
||||
|
||||
@@ -67,8 +68,8 @@ metadata:
|
||||
|
||||
## 能力边界
|
||||
|
||||
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化 / 插件。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。
|
||||
- 用户要配置权限 / 自动化 / 插件时,引导其使用开发态连接前往云端开发(妙搭 web)处理。
|
||||
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。
|
||||
- 用户要配置权限 / 自动化时,引导其使用开发态连接前往云端开发(妙搭 web)处理。
|
||||
|
||||
## app_id 获取
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
## 端到端流程(新建应用)
|
||||
|
||||
`+create(full_stack)` -> `+init`(或手动 `+git-credential-init` + `git clone`)-> `npm install && npm run dev` -> 按需 `+db-*` 调库 -> `git add` + `git commit`(提交本次改动)-> `git push origin sprint/default` -> `+release-create` -> `+release-get`。
|
||||
`+create(full_stack)` -> `+init`(或手动 `+git-credential-init` + `git clone`)-> 读仓库 Skill -> `npm install && npm run dev` -> 按需 `+db-*` 调库 -> `git add` + `git commit`(提交本次改动)-> `git push origin sprint/default` -> `+release-create` -> `+release-get`。
|
||||
|
||||
```bash
|
||||
# 新建 full_stack 应用
|
||||
@@ -36,6 +36,8 @@ lark-cli apps +release-create --app-id app_xxx
|
||||
|
||||
`+init` 是推荐便捷入口;想逐步手动控制时,先 `+git-credential-init` 拿 `repository_url`,再用原生 `git clone` / `git checkout sprint/default`。
|
||||
|
||||
**`+init` 完成后必须执行**:`cat <project-path>/.agents/skills/plugin-guide/SKILL.md`,读取仓库插件指引。该文件包含插件目录、实例配置规则和调用代码生成方式——不读就无法正确集成插件能力。文件不存在则跳过。
|
||||
|
||||
## 改完代码后部署上线
|
||||
|
||||
已拉到本地、改完代码,用户说"推上去""部署""上线""发布到云端"时,按此序列。
|
||||
|
||||
34
skills/lark-apps/references/lark-apps-plugin-install.md
Normal file
34
skills/lark-apps/references/lark-apps-plugin-install.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# apps +plugin-install
|
||||
|
||||
安装插件包到项目。运行时命令事实以 `lark-cli apps +plugin-install --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用户要接入 AI 能力或飞书平台能力,需要先安装对应的插件包。安装后才能创建插件实例。具体有哪些可用插件、该选哪个,读取创建的应用仓库 Skill:`.agents/skills/plugin-guide/SKILL.md`。
|
||||
|
||||
**插件包 ≠ npm 包**:插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。禁止用 `npm install` 代替本命令。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- `--name <key>`:插件包 key(从仓库 Skill 的「AI 插件目录」获取)。不传则批量安装 `actionPlugins` 中声明的所有插件。
|
||||
- `--version <ver>`:指定版本(如 `1.0.0`)。不传则安装最新版。
|
||||
|
||||
在项目根目录下运行(和 npm 一样,无需指定路径)。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
# 安装最新版
|
||||
lark-cli apps +plugin-install --name <plugin-key>
|
||||
|
||||
# 安装指定版本
|
||||
lark-cli apps +plugin-install --name <plugin-key> --version 1.0.0
|
||||
|
||||
# 批量安装已声明的所有插件
|
||||
lark-cli apps +plugin-install
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 已安装同版本会跳过(status=already_installed)。
|
||||
- 失败时 hint 指示原因(网络/版本不存在/package.json 缺失)。
|
||||
21
skills/lark-apps/references/lark-apps-plugin-list.md
Normal file
21
skills/lark-apps/references/lark-apps-plugin-list.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# apps +plugin-list
|
||||
|
||||
列出已声明的插件包及安装状态。运行时命令事实以 `lark-cli apps +plugin-list --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
查看当前项目声明了哪些插件、是否已安装。`declared_not_installed` 状态表示需要运行 `+plugin-install` 安装。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
在项目根目录下运行(和 npm 一样,无需指定路径)。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +plugin-list --format json
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- `data.plugins[]` 包含 `key`、`version`、`status`(`installed` / `declared_not_installed`)。
|
||||
23
skills/lark-apps/references/lark-apps-plugin-uninstall.md
Normal file
23
skills/lark-apps/references/lark-apps-plugin-uninstall.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# apps +plugin-uninstall
|
||||
|
||||
卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用户不再需要某个插件能力时,卸载对应的插件包。卸载前应先删除该插件的所有实例。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- `--name <key>`:要卸载的插件包 key。
|
||||
|
||||
在项目根目录下运行(和 npm 一样,无需指定路径)。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +plugin-uninstall --name <plugin-key>
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 删除 `node_modules/{key}` + 移除 `actionPlugins` 条目。
|
||||
Reference in New Issue
Block a user