diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index 9fd1cfd66..300950711 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -506,6 +506,47 @@ func pluginRemoveActionPlugin(pkg map[string]interface{}, key string) { 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 +} + // pluginParseInstallTarget parses "key[@version]" where version is optional. // For scoped packages like "@scope/name@1.0.0", the split is at the last "@". func pluginParseInstallTarget(s string) (key string, version string) { diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index f3738b617..9676d4b7d 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -77,9 +77,10 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP return appsValidationParamError("--name", "invalid plugin name %q", name) } - // Check if already installed with same version + // 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", } @@ -96,6 +97,18 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP 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, downloadURL, approach) if err != nil { @@ -114,6 +127,9 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP 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 { @@ -127,8 +143,15 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP 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 }