diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go new file mode 100644 index 000000000..849fa7f94 --- /dev/null +++ b/shortcuts/apps/plugin_common.go @@ -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 /.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 "" +} diff --git a/shortcuts/apps/plugin_common_test.go b/shortcuts/apps/plugin_common_test.go new file mode 100644 index 000000000..263f75faf --- /dev/null +++ b/shortcuts/apps/plugin_common_test.go @@ -0,0 +1,254 @@ +// 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) + } +} diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go new file mode 100644 index 000000000..546c2bef7 --- /dev/null +++ b/shortcuts/apps/plugin_install.go @@ -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": [], "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) +} diff --git a/shortcuts/apps/plugin_install_test.go b/shortcuts/apps/plugin_install_test.go new file mode 100644 index 000000000..031a40232 --- /dev/null +++ b/shortcuts/apps/plugin_install_test.go @@ -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() +} diff --git a/shortcuts/apps/plugin_list.go b/shortcuts/apps/plugin_list.go new file mode 100644 index 000000000..e4f9ecf21 --- /dev/null +++ b/shortcuts/apps/plugin_list.go @@ -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 + }, +} diff --git a/shortcuts/apps/plugin_list_test.go b/shortcuts/apps/plugin_list_test.go new file mode 100644 index 000000000..a49bd7df7 --- /dev/null +++ b/shortcuts/apps/plugin_list_test.go @@ -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) + } +} diff --git a/shortcuts/apps/plugin_uninstall.go b/shortcuts/apps/plugin_uninstall.go new file mode 100644 index 000000000..0c1b5c15c --- /dev/null +++ b/shortcuts/apps/plugin_uninstall.go @@ -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 + }, +} diff --git a/shortcuts/apps/plugin_uninstall_test.go b/shortcuts/apps/plugin_uninstall_test.go new file mode 100644 index 000000000..db4584cd2 --- /dev/null +++ b/shortcuts/apps/plugin_uninstall_test.go @@ -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) + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index cf05afdfa..b3ac97f5c 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -63,6 +63,9 @@ func Shortcuts() []common.Shortcut { AppsSessionStop, AppsSessionMessagesList, AppsChat, + AppsPluginInstall, + AppsPluginUninstall, + AppsPluginList, // open API key management AppsOpenAPIKeyList, AppsOpenAPIKeyGet, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 69b5b9654..fccdb62f5 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -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)) } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index b93d82382..b8bc7599e 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -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 ` | [`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 `(仅 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 获取 diff --git a/skills/lark-apps/references/lark-apps-local-dev.md b/skills/lark-apps/references/lark-apps-local-dev.md index d404e34cf..621d22943 100644 --- a/skills/lark-apps/references/lark-apps-local-dev.md +++ b/skills/lark-apps/references/lark-apps-local-dev.md @@ -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 /.agents/skills/plugin-guide/SKILL.md`,读取仓库插件指引。该文件包含插件目录、实例配置规则和调用代码生成方式——不读就无法正确集成插件能力。文件不存在则跳过。 + ## 改完代码后部署上线 已拉到本地、改完代码,用户说"推上去""部署""上线""发布到云端"时,按此序列。 diff --git a/skills/lark-apps/references/lark-apps-plugin-install.md b/skills/lark-apps/references/lark-apps-plugin-install.md new file mode 100644 index 000000000..7d1648587 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-install.md @@ -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(从仓库 Skill 的「AI 插件目录」获取)。不传则批量安装 `actionPlugins` 中声明的所有插件。 +- `--version `:指定版本(如 `1.0.0`)。不传则安装最新版。 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 + +## 示例 + +```bash +# 安装最新版 +lark-cli apps +plugin-install --name + +# 安装指定版本 +lark-cli apps +plugin-install --name --version 1.0.0 + +# 批量安装已声明的所有插件 +lark-cli apps +plugin-install +``` + +## 输出契约 + +- 已安装同版本会跳过(status=already_installed)。 +- 失败时 hint 指示原因(网络/版本不存在/package.json 缺失)。 diff --git a/skills/lark-apps/references/lark-apps-plugin-list.md b/skills/lark-apps/references/lark-apps-plugin-list.md new file mode 100644 index 000000000..58f49fc24 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-list.md @@ -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`)。 diff --git a/skills/lark-apps/references/lark-apps-plugin-uninstall.md b/skills/lark-apps/references/lark-apps-plugin-uninstall.md new file mode 100644 index 000000000..f01f6c30e --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-uninstall.md @@ -0,0 +1,23 @@ +# apps +plugin-uninstall + +卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。 + +## 何时用 + +用户不再需要某个插件能力时,卸载对应的插件包。卸载前应先删除该插件的所有实例。 + +## 命令骨架 + +- `--name `:要卸载的插件包 key。 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 + +## 示例 + +```bash +lark-cli apps +plugin-uninstall --name +``` + +## 输出契约 + +- 删除 `node_modules/{key}` + 移除 `actionPlugins` 条目。