Compare commits

...

11 Commits

Author SHA1 Message Date
zhangli
f0eaed3354 fix(plugin): create temp dir in project path to avoid cross-filesystem EXDEV on Rename
pluginInstallLocal used os.MkdirTemp("") which creates the temp
directory on the system temp partition. On Windows (and some
Linux/macOS setups), the temp partition is on a different filesystem
from the project directory, causing os.Rename to fail with EXDEV.

Use projectPath as the temp dir parent so it is always on the same
filesystem as node_modules.
2026-06-30 18:05:11 +08:00
zhangli
2424d05b01 fix(plugin): harden plugin commands against path traversal, DoS, and agent misuse
Security fixes from PR #1596 security audit:
- Skip symlink/hardlink entries during tgz extraction (Zip Slip)
- Limit tgz entry and download size to 10 MB (OOM/DoS)
- Limit error response body read to 4 KB
- Validate MIAODA_APP_TYPE as numeric to prevent path manipulation
- Add validatePluginKey + secureModulePath to block --name path
  traversal (../../.ssh etc.) for install/uninstall

Usability fix:
- Add explicit 'local command, no --app-id' notice in plugin
  reference docs to prevent agent from incorrectly passing
  --app-id to plugin commands (which read package.json locally)
2026-06-30 17:35:13 +08:00
anngo-nk
cf9e8d512d fix(plugin): fix nolint directive format and nilerr placement in plugin_common.go (#1668)
- Change nolint comment separator from -- to // to satisfy nolintlint
- Move nilerr nolint directive to return statement to suppress nilerr correctly
- Fix forbidigo nolint format for intermediate fmt.Errorf in pluginExtractTGZ
2026-06-30 14:37:58 +08:00
lvxinsheng
9d7f1e4e6b style: gofmt apps_plugin list/uninstall/install_test files
Fix fast-gate Check formatting failure: align struct literal fields in
apps_plugin_list.go and apps_plugin_uninstall.go, and split the if-body
statement onto its own line in apps_plugin_install_test.go.
2026-06-30 14:17:33 +08:00
anngo-nk
be7c05cc97 fix(plugin): resolve CI lint, deadcode, and unit-test failures (#1667)
- Add Scopes: []string{} to plugin-install, plugin-list, plugin-uninstall
  shortcuts to satisfy TestAllShortcutsScopesNotNil
- Remove unused pluginCheckInstalled function (deadcode)
- Fix nilerr: add //nolint:nilerr for intentional best-effort nil returns
- Fix forbidigo: replace bare fmt.Errorf in Execute with typed error,
  add //nolint:forbidigo for intermediate helper errors in pluginExtractTGZ
- Fix errorlint: change %v to %w for cerr in multi-error fmt.Errorf
- Remove all unused //nolint:forbidigo directives from test files
2026-06-30 14:04:38 +08:00
zhangli
9a85ffb4d2 style: gofmt apps plugin files (#1664) 2026-06-30 12:06:33 +08:00
anngo-nk
ff65e614e7 fix(plugin): rename files to apps_ prefix and handle Close() errors (#1655)
- Rename plugin_install/list/uninstall .go files to apps_plugin_ prefix
  for consistency with other files in the package
- Handle f.Close() errors in pluginExtractTGZ to avoid silent data loss
2026-06-30 11:07:14 +08:00
lvxinsheng
9f2fe50f4a feat(apps): add release polling interval time and release time costs 2026-06-29 20:03:51 +08:00
anngo-nk
7d1164dcb4 feat(plugin): add plugin package management commands (#1609)
* feat: add plugin package and instance management commands for apps domain

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

* fix: close install gaps aligned with fullstack-cli

- latest version: re-check installed version after API resolves, skip
  download when already up to date
- actionPlugins sync: ensure package.json record is updated even when
  install is skipped (already_installed path)
- peerDependencies: warn about missing peer deps after extraction
  instead of silently ignoring them

* feat: add +plugin-instance-types command and auto-generate on create/update

Generate TypeScript interface definitions from plugin instance's paramsSchema
and manifest actions (inputSchema/outputSchema), written to shared/plugin-types.ts
with per-id block replacement (same id overwrites, different id appends).

Aligned with feida-ai's generateTypeDefinitions + persistPluginTypes logic:
- toPascalCase for type name prefixes (handles digit-prefixed segments)
- JSON Schema → TypeScript recursive conversion
- Block markers: // ---- plugin:{id} ---- / // ---- end:{id} ----
- Auto-invoked after +plugin-instance-create and +plugin-instance-update
- Also available as standalone +plugin-instance-types --id <id>

* fix: hide +plugin-instance-types from agent (auto-invoked by create/update)

* feat: add plugin skill files for agent workflow guidance

- lark-apps-plugin.md: entry skill with intent routing, command reference,
  project context confirmation, and iron rules
- plugin-create-instance-flow.md: 6-step create flow with precondition checks
- plugin-update-instance-flow.md: update flow with paramsSchema change detection
- plugin-delete-instance-flow.md: delete flow with code reference scanning
- plugin-get-instance-flow.md: query routing for list/get/manifest reads
- plugin-instance-schema.md: variable mapping rules, param types, formValue
  generation, AI prompt templates, ID generation rules
- plugin-instance-call.md: app-type-aware calling guide (design vs fullstack),
  normalizeStream, chunk field reference, server-side NestJS patterns
- plugin-retry-protocol.md: validation failure retry protocol (max 3)
- SKILL.md: add plugin intent route with trigger keywords

* feat: add --local flag to +plugin-install for local tgz installation

Supports installing plugin packages from local .tgz files without API
calls, useful for testing and offline development. Reads plugin key and
version from the extracted package.json inside the tgz.

Also moved Scopes to ConditionalScopes so --local path skips auth.

* fix: improve error messages for plugin install and check

- pluginCheckInstalled: distinguish "directory not exist" (not installed)
  vs "directory exists but manifest.json missing" (not built correctly),
  with specific hints for each case
- pluginResolveVersion: detect non-JSON API response (typically HTML 404
  from unregistered endpoint) and give clear "API not available" message
  instead of misleading "check plugin key spelling"
- Hide --local flag from help (dev/test only, not for agents)

* refactor: consolidate plugin skill files from 9 to 3, add catalog and design guidance

- Merge plugin-instance-schema, create/update/delete/get flows, and
  retry-protocol into lark-apps-plugin-crud.md (Schema + CRUD + retry)
- Merge plugin-catalog into lark-apps-plugin.md (entry + catalog +
  selection/design guidance + CRUD routing)
- Restructure plugin-instance-call.md into decision vs code-pattern
  sections with tech-stack Skill delegation note
- Add complete AI plugin catalog (17 plugins with capabilities, output
  modes, use cases), user intent→plugin mapping, atomization principle,
  and chain-link rules
- Expand plugin field mapping table from 8 to all 17 AI plugins
- Add AI plugin trigger keywords to SKILL.md description for host agent
  skill matching
- Rename files to lark-apps-plugin-* prefix for consistency

* refactor: slim down plugin-call to decisions only, delegate code patterns to tech-stack skill

Remove all code pattern content (capabilityClient imports, normalizeStream,
NestJS injection, streaming examples, chunk field table) from
lark-apps-plugin-call.md. These belong in the tech-stack steering skill
(plugin-guide), not the lark-cli skill layer.

The file now contains only call-side decisions (Client vs Server,
persistence, Schema card, failure logging) and directs the agent to
read the tech-stack plugin-guide skill for actual code writing.

* fix: use absolute project-path for tech-stack skill location in plugin-call

Replace relative .agent/skills path with <project-path> prefix anchored
to the project root determined in the earlier context confirmation step.
Add fallback path and minimal call rules when skill file doesn't exist.

* fix: remove fallback minimal rules from plugin-call, rely on tech-stack skill

* fix: require reading project plugin-guide skill before writing call code

* fix: improve plugin error hints for AI agent friendliness

- Version mismatch warning now includes the exact +plugin-install
  command to update
- Batch install (+plugin-install without --name) now re-installs
  when declared version differs from installed version
- Remove --local flag from user-facing error hints (internal-only)

* docs: add plugin package ≠ npm package distinction to skill docs

Add a comparison table and iron law #6 to prevent agents from confusing
+plugin-install with npm install, which was a recurring failure in
multi-model evaluation.

* fix: block plugin uninstall when instances still reference the package

Add pluginCheckDependentInstances to scan capabilities/ for instances
that reference the plugin being uninstalled. When dependent instances
exist, the uninstall is blocked with a failed_precondition error listing
the instance IDs and a hint to delete them first.

* fix: update plugin API paths to match new OpenAPI gateway routes

- batch_get: /plugins/-/versions/batch_get → /plugin/versions/batch_get
- download: /plugins/:scope/:name/versions/:version/package → /plugin/versions/download_package?plugin_key=&version=

* fix: update plugin install to match final OpenAPI gateway protocol

- batch_query: URL /plugin/versions/batch_query, request uses plugin_keys
  array + latest_only boolean, response uses flat data.items list with
  plugin_key/plugin_version fields
- download: changed from GET+query to POST+JSON body {plugin_key, plugin_version},
  response is binary tgz stream (supportFileDownload)
- scope: spark:plugin:readonly → spark:app:read

* fix: align dry-run output with new batch_query + download_package request format

* fix: match actual API response field names (key/version instead of plugin_key/plugin_version)

* docs: strengthen plugin reference reading rules from advisory to mandatory

Change lark-apps-plugin.md from implicit to explicit required reading
for any plugin work. Replace soft '按需读' with bold '必读' for all three
plugin reference files. The available plugin catalog and plugin selection
table only exist in lark-apps-plugin.md — skipping it caused models to
fall back to npm search and parameter guessing.

* fix: remove call example annotation from types, add skill reference instead

* refactor: streamline plugin skill files

* refactor: 插件 PE 下沉到仓库,lark-cli 侧精简为命令参考

- 删除旧的 3 个插件 reference(plugin.md / plugin-crud.md / plugin-call.md),
  其中的 Schema 规则、CRUD 流程、插件目录、Prompt 模板等内容已下沉到
  应用仓库 .agents/skills/plugin-guide/SKILL.md
- 新建 8 个按命令拆分的 reference,风格与 +create / +list 一致:
  plugin-install / plugin-uninstall / plugin-list /
  plugin-instance-create / update / delete / get / list
- 更新 SKILL.md:description 泛化触发词(不再列举 17 个具体能力),
  意图路由引导先读仓库 Skill 再看 CLI 命令参考

* fix(plugin):simplify skill docs and resolve plugin version from actionPlugins

Remove redundant skill documentation (pre-check table, validation error
examples, JSON return samples, fullstack-cli references) that duplicate
CLI error hints.  Make --plugin version optional and resolve from
package.json actionPlugins.  Drop unused createdBy field.

* fix: 去掉 reference 中的具体插件名和参数示例,强制 agent 读仓库 Skill

- 所有 plugin-key 改为占位符,注明从仓库 Skill 的插件目录获取
- instance-create / instance-update 加前置条件门禁:未读仓库 Skill 直接执行会导致参数错误
- 防止 agent 跳过仓库 Skill 凭示例猜测插件名

* fix(plugin): resolve real paths in dry-run output for instance commands

Replace <capabilities_dir> placeholders with resolved paths so models
can see actual file locations before execution. Add version_source,
types_output, and scan_dir fields to describe implicit behaviors.

* refactor(plugin): hide instance commands, delegate to repo Skill

Hide +plugin-instance-create/update/delete/get/list from CLI help.
Remove instance reference files from lark-apps skill. Route instance
CRUD and call code generation to project repo plugin-guide skill.

Go instance code preserved, just hidden.

* refactor: 删除 plugin-instance 5 个 CLI 命令,改由仓库 Skill 引导 agent 直接操作文件

- 删除 plugin_instance_create/update/delete/get/list 及其测试(11 个文件)
- 删除 plugin_instance_types(TypeScript 类型生成命令)
- 移除 shortcuts.go 中的 6 个注册项
- 清理 plugin_common.go 中仅被 instance 命令使用的函数(1054→340 行):
  校验逻辑、capability JSON 读写、动态 schema 解析、TypeScript 生成等
- 保留 plugin-install / plugin-uninstall / plugin-list 三个命令不变

插件实例的 CRUD 操作改由仓库 Skill 引导 agent 直接读写 capabilities/*.json,
验证规则写在 Skill 中由 agent 自校验。

* refactor(plugin): remove --project-path flag and split --name into --name + --version

- Remove --project-path from plugin-install/list/uninstall (use cwd like npm)
- Split --name key@version into separate --name and --version flags
- Remove pluginParseInstallTarget (no longer needed)
- Improve DryRun desc and error hints for --version usage
- Update skill docs to reflect new flag structure
- Tests use chdirTest helper instead of --project-path

* feat(plugin): add Examples to --help for plugin-install/list/uninstall

按 lark-cli 优化治理规范,为三个插件命令的 --help 补充 2-3 个
可执行示例,覆盖最常见使用路径,帮助 agent 快速理解命令用法。

* fix(plugin): address PR #1609 review findings

- Fix hint referencing non-existent +plugin-instance-delete command,
  point to repo plugin-guide Skill instead
- Remove undeclared --capabilities-dir flag, simplify pluginResolveCapDir
  to env-only resolution, fix ambiguous hint to suggest env vars
- Reclassify download errors from file_io to network/api with proper
  hints and retryable marking
- Slim SKILL.md routing row, move judgment rules to plugin-install reference
- Rename --local flag to --file to align with CLI conventions

* fix(skill): restore plugin routing row with judgment rules, fix markdown formatting

Revert SKILL.md routing row to keep full judgment rules and repo Skill
directive inline. Fix bold marker spacing and restore missing table column.
Revert reference to original content without duplicated rules.

* fix(plugin): revert SKILL.md to pre-review version, fix shortcut count test

Restore SKILL.md plugin routing row to original version with full
judgment rules and repo Skill directive. Update shortcut count test
from 60 to 63 to account for 3 new plugin commands.

* fix(plugin):fix lark-apps skill docs which is about plugin

* fix(plugin):correct plugin skill md

* fix(plugin):correct plugin md

* fix(plugin):correct plugin and local dev skills md

* fix(plugin):correct apps plugin skills md

* fix(lark-apps): move repo skill reading hint to post-init phase

将「仓库 Skill 优先」从 SKILL.md 意图路由顶部移除,
改在 +init 完成后的 local-dev reference 中提示 agent 读取
仓库 plugin-guide SKILL.md,解决应用未初始化时 repo skill
不存在导致 agent 无法获取插件知识的时序问题。

* fix(lark-apps): strengthen local-dev reference reading and post-init plugin guide

- SKILL.md 路由表:local-dev.md 从"按需读取"提升为"执行前必读"
- local-dev.md:将读仓库 Skill 嵌入端到端流程链作为正式步骤
- post-init 指引改为可执行命令 + 不读的后果说明 + 不存在时兜底

---------

Co-authored-by: zhangli <zhangli.268@bytedance.com>
2026-06-29 11:22:01 +08:00
wangwei
2362437de9 fix: improve env-pull dev database hint (#1614) 2026-06-26 17:57:48 +08:00
陈兴炀
8a5c1dc547 test(apps): align db dry-run e2e with --environment rename and dev default
db dry-run tests still used the removed --env flag and asserted the old
online default, breaking the Run dry-run E2E tests CI step after the
--environment hard rename and dev-default change. Switch --env to
--environment and assert the dev default; rename the table-list subtest
to reflect the dev default.
2026-06-26 17:18:27 +08:00
21 changed files with 1912 additions and 17 deletions

View File

@@ -83,7 +83,7 @@ var AppsEnvPull = common.Shortcut{
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
if err != nil {
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
return withAppsHint(err, envPullAPIErrorHint(err, appID))
}
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
@@ -126,6 +126,27 @@ func envPullVarsBody() map[string]interface{} {
}
}
func envPullAPIErrorHint(err error, appID string) string {
if isEnvPullDevDBNotInitializedError(err) {
appID = strings.TrimSpace(appID)
if appID == "" {
appID = "<app_id>"
}
return fmt.Sprintf("dev database is not initialized; preview creation with `lark-cli apps +db-env-create --app-id %s --environment dev --dry-run`, then run `lark-cli apps +db-env-create --app-id %s --environment dev --sync-data --yes` after confirming the irreversible split", appID, appID)
}
return appIDListHint
}
func isEnvPullDevDBNotInitializedError(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
message := strings.ToLower(p.Message)
return strings.Contains(message, "multi-environment database is not initialized") ||
(strings.Contains(message, "invalid db branch") && strings.Contains(message, "dev"))
}
func resolveEnvPullTarget(projectPath string) (string, string, error) {
if strings.TrimSpace(projectPath) == "" {
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.

View File

@@ -592,6 +592,38 @@ func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
}
}
func TestAppsEnvPull_DevDBNotInitializedHintPointsToDBEnvCreate(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": -1,
"msg": "Multi-environment database is not initialized for this app. Invalid DB Branchdev",
},
OnMatch: func(req *http.Request) {
assertEnvPullBody(t, req)
},
})
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
factory, stdout,
)
p := requireAppsAPIProblem(t, err)
if p.Code != -1 {
t.Fatalf("code = %d, want -1", p.Code)
}
for _, want := range []string{"+db-env-create", "--app-id app_x", "--environment dev", "--dry-run", "--yes"} {
if !strings.Contains(p.Hint, want) {
t.Fatalf("hint missing %q: %q", want, p.Hint)
}
}
if strings.Contains(p.Hint, "apps +list") {
t.Fatalf("hint should not point to app-id/list recovery for missing dev database: %q", p.Hint)
}
}
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()

View File

@@ -0,0 +1,419 @@
// 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"},
Scopes: []string{},
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
}
if key := strings.TrimSpace(rctx.Str("name")); key != "" {
if err := validatePluginKey(key); err != nil {
return err
}
}
return pluginCheckProjectDir(projectPath)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
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, err := secureModulePath(projectPath, key)
if err != nil {
return err
}
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract.
return appsFileIOError(err, "cannot clean %s", destDir)
}
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 errs.NewInternalError(errs.SubtypeUnknown, "install %s failed", key).WithCause(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(projectPath, ".plugin-tmp-*") //nolint:forbidigo // same FS as node_modules to avoid EXDEV on Rename
if err != nil {
return appsFileIOError(err, "cannot create temp dir")
}
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, err := secureModulePath(projectPath, key)
if err != nil {
return err
}
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo
return appsFileIOError(err, "cannot clean %s", destDir)
}
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}.
const pluginDownloadMaxBytes = 10 * 1024 * 1024
func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) {
apiPath := apiBasePath + "/plugin/versions/download_package"
body, _ := json.Marshal(map[string]string{
"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(io.LimitReader(resp.Body, 4096))
hint := "check plugin key and version spelling"
if resp.StatusCode == 403 {
hint = "download token may have expired; retry the install to get a fresh token"
} 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)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, pluginDownloadMaxBytes+1))
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err).
WithHint("check network connectivity and retry").
WithRetryable().
WithCause(err)
}
if len(data) > pluginDownloadMaxBytes {
return nil, errs.NewAPIError(errs.SubtypeUnknown, "plugin package %s@%s exceeds %d MB size limit", key, version, pluginDownloadMaxBytes/(1024*1024)).
WithHint("contact plugin maintainer to reduce package size")
}
return data, nil
}

View 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 {
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)
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
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"))
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 {
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()
}

View File

@@ -0,0 +1,81 @@
// 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 locally installed plugin packages and their installation status",
Risk: "read",
Scopes: []string{},
Tips: []string{
"Example: lark-cli apps +plugin-list",
"Example: lark-cli apps +plugin-list --format pretty",
},
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
},
}

View 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)
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
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()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chdir(prev) }) //nolint: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 {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"os"
"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",
Scopes: []string{},
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 key := strings.TrimSpace(rctx.Str("name")); key == "" {
return appsValidationParamError("--name", "--name is required")
} else if err := validatePluginKey(key); err != nil {
return err
}
projectPath, err := pluginResolveProjectPath("")
if err != nil {
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, err := secureModulePath(projectPath, key)
if err != nil {
return err
}
if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory.
return appsFileIOError(err, "cannot remove %s", pkgDir)
}
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
},
}

View 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)
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
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) {
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)
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
// Create a capability that references this plugin
capDir := filepath.Join(dir, "server", "capabilities")
os.MkdirAll(capDir, 0o755)
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 {
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)
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
// Create a capability that references a DIFFERENT plugin — should not block
capDir := filepath.Join(dir, "server", "capabilities")
os.MkdirAll(capDir, 0o755)
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) {
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)
}
}

View File

@@ -0,0 +1,420 @@
// 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"
"strconv"
"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
}
// validatePluginKey validates a plugin key for use in filesystem paths.
// Rejects empty, ".", "..", absolute paths, path traversal, and control characters.
func validatePluginKey(key string) error {
if key == "" || key == "." || key == ".." {
return appsValidationError("invalid plugin key: must not be empty, \".\", or \"..\"")
}
if filepath.IsAbs(key) {
return appsValidationError("invalid plugin key: must not be an absolute path: %q", key)
}
if strings.Contains(key, "..") {
return appsValidationError("invalid plugin key: must not contain path traversal: %q", key)
}
for _, r := range key {
if r < 32 || r == 127 {
return appsValidationError("invalid plugin key: contains control character (code %d)", r)
}
}
return nil
}
// secureModulePath validates the plugin key and joins it with
// projectPath/node_modules, asserting the result stays within node_modules.
func secureModulePath(projectPath, key string) (string, error) {
if err := validatePluginKey(key); err != nil {
return "", err
}
nodeModules := filepath.Join(projectPath, "node_modules")
resolved := filepath.Clean(filepath.Join(nodeModules, key))
expectedPrefix := filepath.Clean(nodeModules) + string(filepath.Separator)
if !strings.HasPrefix(resolved+string(filepath.Separator), expectedPrefix) {
return "", appsValidationError("plugin key %q resolves outside node_modules", key)
}
return resolved, nil
}
// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback:
// 1. MIAODA_CAPABILITIES_DIR env var
// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities)
// 2.5 Read .env.local for MIAODA_APP_TYPE
// 3. Detect by checking which directories exist under projectPath
func pluginResolveCapDir(projectPath string) (string, error) {
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" {
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")
if appType == "" {
appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE")
}
if appType != "" {
if _, err := strconv.Atoi(appType); err != nil {
return "", appsValidationError("MIAODA_APP_TYPE must be a number, got %q", appType).
WithHint("set MIAODA_APP_TYPE to a valid numeric value in .env.local")
}
}
if appType == "6" {
return filepath.Join(projectPath, "shared", "capabilities"), nil
}
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 {
return nil //nolint:nilerr // best-effort: no capabilities dir means no conflict
}
caps, err := pluginListCapabilities(capDir)
if err != nil {
return nil //nolint:nilerr // best-effort: scan failure should not block uninstall
}
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")
}
// ── 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 ──
const pluginExtractMaxBytes = 10 * 1024 * 1024
// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the
// first path component (npm convention: tarballs contain a "package/" prefix).
// Path traversal entries are silently skipped.
func pluginExtractTGZ(r io.Reader, destDir string) error {
gz, err := gzip.NewReader(r)
if err != nil {
return fmt.Errorf("gzip: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed
}
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) //nolint:forbidigo // intermediate helper error; callers wrap as typed
}
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.TypeSymlink, tar.TypeLink:
continue
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction.
return err
}
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, io.LimitReader(tr, pluginExtractMaxBytes)); err != nil {
if cerr := f.Close(); cerr != nil {
return fmt.Errorf("copy tar entry: %w; close file: %w", err, cerr) //nolint:forbidigo // intermediate helper error; callers wrap as typed
}
return err
}
if err := f.Close(); err != nil {
return err
}
}
}
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 ""
}

View 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()
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 {
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 {
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 {
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 {
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 {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil {
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 {
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 {
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 {
t.Fatal(err)
}
}

View File

@@ -63,6 +63,9 @@ func Shortcuts() []common.Shortcut {
AppsSessionStop,
AppsSessionMessagesList,
AppsChat,
AppsPluginInstall,
AppsPluginUninstall,
AppsPluginList,
// open API key management
AppsOpenAPIKeyList,
AppsOpenAPIKeyGet,

View File

@@ -19,11 +19,12 @@ import (
// - 7 filelist/get/sign/download/upload/delete/quota-get
// - 3 git-credential
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset= 60。
func TestAppsShortcuts_Returns60(t *testing.T) {
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 plugininstall/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))
}
}

View File

@@ -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 获取

View File

@@ -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`,读取仓库插件指引。该文件包含插件目录、实例配置规则和调用代码生成方式——不读就无法正确集成插件能力。文件不存在则跳过。
## 改完代码后部署上线
已拉到本地、改完代码,用户说"推上去""部署""上线""发布到云端"时,按此序列。

View File

@@ -0,0 +1,36 @@
# apps +plugin-install
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
安装插件包到项目。运行时命令事实以 `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 缺失)。

View File

@@ -0,0 +1,23 @@
# apps +plugin-list
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
列出已声明的插件包及安装状态。运行时命令事实以 `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`)。

View File

@@ -0,0 +1,25 @@
# apps +plugin-uninstall
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。
## 何时用
用户不再需要某个插件能力时,卸载对应的插件包。卸载前应先删除该插件的所有实例。
## 命令骨架
- `--name <key>`:要卸载的插件包 key。
在项目根目录下运行(和 npm 一样,无需指定路径)。
## 示例
```bash
lark-cli apps +plugin-uninstall --name <plugin-key>
```
## 输出契约
- 删除 `node_modules/{key}` + 移除 `actionPlugins` 条目。

View File

@@ -22,7 +22,7 @@ lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run
## 输出契约
- 成功读取 `data.release_id``data.status``release_id` 是后续 `+release-get` 的入参。
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询。
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min整体超时时间大约 5min
- `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。
## Agent 规则

View File

@@ -24,7 +24,7 @@ func TestAppsDBEnvCreateDryRun(t *testing.T) {
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run"},
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
@@ -40,7 +40,7 @@ func TestAppsDBEnvCreateDryRun(t *testing.T) {
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--dry-run"},
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)

View File

@@ -46,7 +46,7 @@ func TestAppsDBExecuteDryRun(t *testing.T) {
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--env", "online", "--dry-run"},
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--environment", "online", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)

View File

@@ -19,7 +19,7 @@ import (
func TestAppsDBTableListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultsToOnlineAndPageSize20", func(t *testing.T) {
t.Run("DefaultsToDevAndPageSize20", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
@@ -32,7 +32,7 @@ func TestAppsDBTableListDryRun(t *testing.T) {
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String())
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
"empty page_token must be omitted")
@@ -46,7 +46,7 @@ func TestAppsDBTableListDryRun(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-table-list",
"--app-id", "app_x", "--env", "dev",
"--app-id", "app_x", "--environment", "dev",
"--page-size", "50", "--page-token", "cursor-abc",
"--dry-run"},
DefaultAs: "user",