mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 02:54:04 +08:00
Add 8 new shortcut commands under `lark-cli apps`: Plugin package management (aligned with fullstack-cli): - +plugin-install: download tgz, extract to node_modules, update package.json - +plugin-uninstall: remove from node_modules and package.json actionPlugins - +plugin-list: list declared plugins with installation status Plugin instance CRUD (aligned with feida-ai): - +plugin-instance-create: validate + write capability JSON with formValue validation - +plugin-instance-update: merge mutable fields, re-validate formValue - +plugin-instance-delete: idempotent file removal - +plugin-instance-get: read capability JSON - +plugin-instance-list: scan capabilities directory Shared infrastructure (plugin_common.go): - 4-level capabilities dir resolution (flag → env → .env.local MIAODA_APP_TYPE → detection) - formValue validation ported from feida-ai (5 rules: forbidden Handlebars, paramsSchema type constraints, input ref existence, unconsumed params, array double-wrap auto-fix) - tgz extraction with path traversal protection - package.json actionPlugins management - Install version check with mismatch warnings
282 lines
8.6 KiB
Go
282 lines
8.6 KiB
Go
// 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 TestPluginInstanceCreate_Basic(t *testing.T) {
|
|
dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin")
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
|
|
err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{
|
|
"+plugin-instance-create",
|
|
"--plugin", "@test/my-plugin@1.0.0",
|
|
"--name", "My Instance",
|
|
"--form-value", `{"prompt":"hello"}`,
|
|
"--project-path", dir,
|
|
"--format", "json",
|
|
"--as", "user",
|
|
}, factory, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
var env map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("invalid JSON output: %v", err)
|
|
}
|
|
data, _ := env["data"].(map[string]interface{})
|
|
if data["id"] != "test-my-plugin" {
|
|
t.Errorf("id = %v, want test-my-plugin (auto-derived)", data["id"])
|
|
}
|
|
if data["pluginKey"] != "@test/my-plugin" {
|
|
t.Errorf("pluginKey = %v, want @test/my-plugin", data["pluginKey"])
|
|
}
|
|
|
|
// Verify file was written
|
|
capPath := filepath.Join(dir, "server", "capabilities", "test-my-plugin.json")
|
|
capData, err := os.ReadFile(capPath) //nolint:forbidigo
|
|
if err != nil {
|
|
t.Fatalf("capability file not created: %v", err)
|
|
}
|
|
var cap map[string]interface{}
|
|
if err := json.Unmarshal(capData, &cap); err != nil {
|
|
t.Fatalf("invalid capability JSON: %v", err)
|
|
}
|
|
if cap["name"] != "My Instance" {
|
|
t.Errorf("cap.name = %v, want My Instance", cap["name"])
|
|
}
|
|
}
|
|
|
|
func TestPluginInstanceCreate_CustomID(t *testing.T) {
|
|
dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin")
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
|
|
err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{
|
|
"+plugin-instance-create",
|
|
"--id", "custom-summary",
|
|
"--plugin", "@test/my-plugin@2.0.0",
|
|
"--name", "Custom",
|
|
"--form-value", `{"key":"val"}`,
|
|
"--project-path", dir,
|
|
"--format", "json",
|
|
"--as", "user",
|
|
}, factory, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
capPath := filepath.Join(dir, "server", "capabilities", "custom-summary.json")
|
|
if _, err := os.Stat(capPath); err != nil { //nolint:forbidigo
|
|
t.Fatalf("capability file not created at custom id path: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPluginInstanceCreate_WithParamsSchema(t *testing.T) {
|
|
dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin")
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
|
|
err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{
|
|
"+plugin-instance-create",
|
|
"--plugin", "@test/my-plugin@1.0.0",
|
|
"--name", "WithSchema",
|
|
"--form-value", `{"prompt":"{{input.text}}"}`,
|
|
"--params-schema", `{"type":"object","properties":{"text":{"type":"string","description":"user input text"}}}`,
|
|
"--project-path", dir,
|
|
"--format", "json",
|
|
"--as", "user",
|
|
}, factory, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
capPath := filepath.Join(dir, "server", "capabilities", "test-my-plugin.json")
|
|
capData, err := os.ReadFile(capPath) //nolint:forbidigo
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var cap map[string]interface{}
|
|
if err := json.Unmarshal(capData, &cap); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := cap["paramsSchema"]; !ok {
|
|
t.Error("paramsSchema should be present in capability")
|
|
}
|
|
}
|
|
|
|
func TestPluginInstanceCreate_DuplicateID(t *testing.T) {
|
|
dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin")
|
|
capDir := filepath.Join(dir, "server", "capabilities")
|
|
writeTestCapJSON(t, capDir, "existing.json", map[string]interface{}{"id": "existing"})
|
|
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{
|
|
"+plugin-instance-create",
|
|
"--id", "existing",
|
|
"--plugin", "@test/my-plugin@1.0.0",
|
|
"--name", "Dup",
|
|
"--form-value", `{}`,
|
|
"--project-path", dir,
|
|
"--as", "user",
|
|
}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for duplicate id")
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed error, got %T: %v", err, err)
|
|
}
|
|
if p.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype = %q, want invalid_argument", p.Subtype)
|
|
}
|
|
}
|
|
|
|
func TestPluginInstanceCreate_ForceOverwrite(t *testing.T) {
|
|
dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin")
|
|
capDir := filepath.Join(dir, "server", "capabilities")
|
|
writeTestCapJSON(t, capDir, "existing.json", map[string]interface{}{"id": "existing", "name": "Old"})
|
|
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{
|
|
"+plugin-instance-create",
|
|
"--id", "existing",
|
|
"--plugin", "@test/my-plugin@1.0.0",
|
|
"--name", "New",
|
|
"--form-value", `{}`,
|
|
"--force",
|
|
"--project-path", dir,
|
|
"--format", "json",
|
|
"--as", "user",
|
|
}, factory, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error with --force: %v", err)
|
|
}
|
|
|
|
capData, _ := os.ReadFile(filepath.Join(capDir, "existing.json")) //nolint:forbidigo
|
|
var cap map[string]interface{}
|
|
json.Unmarshal(capData, &cap)
|
|
if cap["name"] != "New" {
|
|
t.Errorf("name = %v, want New (overwritten)", cap["name"])
|
|
}
|
|
}
|
|
|
|
func TestPluginInstanceCreate_PluginNotInstalled(t *testing.T) {
|
|
dir := setupPluginTestProject(t, "server")
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
|
|
err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{
|
|
"+plugin-instance-create",
|
|
"--plugin", "@test/not-installed@1.0.0",
|
|
"--name", "Fail",
|
|
"--form-value", `{}`,
|
|
"--project-path", dir,
|
|
"--as", "user",
|
|
}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error when plugin not installed")
|
|
}
|
|
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 TestPluginInstanceCreate_InvalidPluginFormat(t *testing.T) {
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
dir := setupPluginTestProject(t, "server")
|
|
|
|
err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{
|
|
"+plugin-instance-create",
|
|
"--plugin", "no-version",
|
|
"--name", "Fail",
|
|
"--form-value", `{}`,
|
|
"--project-path", dir,
|
|
"--as", "user",
|
|
}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid plugin format")
|
|
}
|
|
}
|
|
|
|
func TestPluginInstanceCreate_InvalidJSON(t *testing.T) {
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
dir := setupPluginTestProject(t, "server")
|
|
|
|
err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{
|
|
"+plugin-instance-create",
|
|
"--plugin", "@test/p@1.0.0",
|
|
"--name", "Fail",
|
|
"--form-value", `not json`,
|
|
"--project-path", dir,
|
|
"--as", "user",
|
|
}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestPluginInstanceCreate_AutoCreateCapDir(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 := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo
|
|
t.Fatal(err)
|
|
}
|
|
pluginKey := "@test/my-plugin"
|
|
manifestDir := filepath.Join(dir, "node_modules", pluginKey)
|
|
if err := os.MkdirAll(manifestDir, 0o755); err != nil { //nolint:forbidigo
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(manifestDir, "manifest.json"), []byte(`{}`), 0o644); err != nil { //nolint:forbidigo
|
|
t.Fatal(err)
|
|
}
|
|
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{
|
|
"+plugin-instance-create",
|
|
"--plugin", "@test/my-plugin@1.0.0",
|
|
"--name", "AutoDir",
|
|
"--form-value", `{}`,
|
|
"--project-path", dir,
|
|
"--format", "json",
|
|
"--as", "user",
|
|
}, factory, stdout)
|
|
if err != nil {
|
|
t.Fatalf("should auto-create capabilities dir: %v", err)
|
|
}
|
|
|
|
capPath := filepath.Join(dir, "server", "capabilities", "test-my-plugin.json")
|
|
if _, err := os.Stat(capPath); err != nil { //nolint:forbidigo
|
|
t.Fatalf("capability file not created: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
// setupPluginTestProjectWithManifest creates a project dir with package.json,
|
|
// capabilities dir, and a minimal manifest.json for the given plugin key.
|
|
func setupPluginTestProjectWithManifest(t *testing.T, appType, pluginKey string) string {
|
|
t.Helper()
|
|
dir := setupPluginTestProject(t, appType)
|
|
manifestDir := filepath.Join(dir, "node_modules", pluginKey)
|
|
if err := os.MkdirAll(manifestDir, 0o755); err != nil { //nolint:forbidigo
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(manifestDir, "manifest.json"), []byte(`{"actions":[]}`), 0o644); err != nil { //nolint:forbidigo
|
|
t.Fatal(err)
|
|
}
|
|
return dir
|
|
}
|