diff --git a/README.md b/README.md index 6b08213..80083ef 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ clime plugin install opencli --npm @jackwener/opencli clime opencli --help clime plugin list -clime plugin update all +clime plugin update all # update installed plugins only clime plugin uninstall opencli ``` @@ -85,8 +85,11 @@ Bootstrap CLI plugins from a shared manifest: ```sh clime init # saved source or built-in defaults clime init https://example.com/tools.yaml # use and remember your team's manifest +clime init ./tools.yaml # remember an absolute local path ``` +The URL or absolute path is saved as `init_url` in `~/.clime/plugins.yaml` and reused by `clime init` when no source is provided. + Share a `skills.yaml` separately and apply it with `clime skills sync --manifest ./skills.yaml`. ## More commands diff --git a/cmd/init.go b/cmd/init.go index 1115756..f6c2af7 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "net/url" + "path/filepath" "strings" uicli "github.com/alperdrsnn/clime" @@ -29,9 +30,10 @@ var initCmd = &cobra.Command{ If a URL is provided, the plugin list is fetched from that remote YAML file and remembered for future runs. -If a local file path is provided, the plugin list is loaded from that YAML file. -Otherwise, the remembered URL is used, falling back to the built-in default -plugin list when no URL has been recorded.`, +If a local file path is provided, the plugin list is loaded from that YAML file +and its absolute path is remembered for future runs. +Otherwise, the remembered URL or path is used, falling back to the built-in +default plugin list when no source has been recorded.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { manifest, err := plugin.LoadManifest() @@ -40,10 +42,14 @@ plugin list when no URL has been recorded.`, } source := manifest.InitURL - shouldRecord := false if len(args) > 0 { source = args[0] - shouldRecord = isURL(source) + if !isURL(source) { + source, err = filepath.Abs(source) + if err != nil { + return fmt.Errorf("failed to resolve init path: %w", err) + } + } } plugins, err := resolvePlugins(source) @@ -51,10 +57,10 @@ plugin list when no URL has been recorded.`, return err } - if shouldRecord && source != manifest.InitURL { + if len(args) > 0 && source != manifest.InitURL { manifest.InitURL = source if err := manifest.Save(); err != nil { - return fmt.Errorf("failed to save init URL: %w", err) + return fmt.Errorf("failed to save init source: %w", err) } } diff --git a/cmd/init_test.go b/cmd/init_test.go index e967840..382d289 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "path/filepath" "testing" "github.com/git-hulk/clime/internal/plugin" @@ -8,6 +10,41 @@ import ( "github.com/stretchr/testify/require" ) +func TestInitRemembersLocalSource(t *testing.T) { + for _, absolute := range []bool{false, true} { + name := "relative path" + if absolute { + name = "absolute path" + } + t.Run(name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) + sourcePath, err := filepath.Abs("plugins.yaml") + require.NoError(t, err) + require.NoError(t, os.WriteFile(sourcePath, []byte("plugins:\n - name: remembered\n"), 0644)) + + manifest := &plugin.Manifest{InitURL: "https://example.com/previous.yaml"} + manifest.Add("remembered", "1.0.0", plugin.SourceTypeGitHub, "example/clime-remembered", "") + require.NoError(t, manifest.Save()) + + source := "plugins.yaml" + if absolute { + source = sourcePath + } + require.NoError(t, initCmd.RunE(initCmd, []string{source})) + manifest, err = plugin.LoadManifest() + require.NoError(t, err) + require.Equal(t, sourcePath, manifest.InitURL) + + t.Chdir(t.TempDir()) + output := captureStdout(t, func() { + require.NoError(t, initCmd.RunE(initCmd, nil)) + }) + require.Contains(t, output, "Skipping 1 already installed plugin(s): remembered") + }) + } +} + func TestFilterPluginsByTags(t *testing.T) { plugins := []plugin.Plugin{ {Name: "core"}, // untagged diff --git a/cmd/plugin.go b/cmd/plugin.go index 8a2a625..a265e94 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -434,7 +434,7 @@ var pluginUninstallCmd = &cobra.Command{ var pluginUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update an installed plugin", - Long: "Updates a plugin from its configured source. GitHub-based plugins update to the latest release. Script-based plugins rerun their install script. Repo is resolved from --repo, manifest, or the default git-hulk/clime- convention. Use '*' or 'all' to update all managed plugins.", + Long: "Updates a plugin from its configured source. GitHub-based plugins update to the latest release. Script-based plugins rerun their install script. Repo is resolved from --repo, manifest, or the default git-hulk/clime- convention. Use '*' or 'all' to update all installed managed plugins.", Args: cobra.ExactArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 0 { @@ -521,13 +521,16 @@ func runPluginUpdateAll() error { if name == "" || seen[name] { continue } + if _, installed := plugin.Find(name); !installed { + continue + } seen[name] = true names = append(names, name) } sort.Strings(names) if len(names) == 0 { - terminal.Warning("No managed plugins found.") + terminal.Warning("No installed managed plugins found.") terminal.Info("Install a plugin with: clime plugin install ") return nil } diff --git a/cmd/plugin_update_test.go b/cmd/plugin_update_test.go new file mode 100644 index 0000000..a223035 --- /dev/null +++ b/cmd/plugin_update_test.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/git-hulk/clime/internal/plugin" + "github.com/stretchr/testify/require" +) + +func TestPluginUpdateAllSkipsMissingPlugins(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a shell script to simulate npm") + } + t.Setenv("HOME", t.TempDir()) + binDir := t.TempDir() + t.Setenv("PATH", binDir) + updateLog := filepath.Join(t.TempDir(), "updates") + t.Setenv("CLIME_TEST_NPM_LOG", updateLog) + require.NoError(t, os.WriteFile(filepath.Join(binDir, "npm"), []byte(`#!/bin/sh +case "$1" in + update) printf '%s\n' "$3" >> "$CLIME_TEST_NPM_LOG" ;; + list) printf '{"dependencies":{"%s":{"version":"2.0.0"}}}\n' "$3" ;; + *) exit 1 ;; +esac +`), 0755)) + + managedDir, err := plugin.PluginBinDir() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(managedDir, 0755)) + managedPath := filepath.Join(managedDir, "clime-managed") + pathPlugin := filepath.Join(binDir, "clime-path-only") + for _, path := range []string{managedPath, pathPlugin, filepath.Join(binDir, "clime-unmanaged")} { + require.NoError(t, os.WriteFile(path, nil, 0755)) + } + + manifest := &plugin.Manifest{} + for _, name := range []string{"managed", "missing", "path-only"} { + manifest.Add(name, "1.0.0", plugin.SourceTypeNpm, name, "") + } + require.NoError(t, manifest.Save()) + + require.NoError(t, pluginUpdateCmd.RunE(pluginUpdateCmd, []string{"all"})) + updates, err := os.ReadFile(updateLog) + require.NoError(t, err) + require.Equal(t, "managed\npath-only\n", string(updates)) + manifest, err = plugin.LoadManifest() + require.NoError(t, err) + require.Len(t, manifest.Plugins, 3) + for _, entry := range manifest.Plugins { + version := "2.0.0" + if entry.Name == "missing" { + version = "1.0.0" + } + require.Equal(t, version, entry.Version) + } + + require.NoError(t, os.Remove(managedPath)) + require.NoError(t, os.Remove(pathPlugin)) + require.NoError(t, os.Remove(updateLog)) + require.NoError(t, pluginUpdateCmd.RunE(pluginUpdateCmd, []string{"all"})) + require.NoFileExists(t, updateLog) +} diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go index a7f6255..7c7b0b8 100644 --- a/internal/plugin/manifest.go +++ b/internal/plugin/manifest.go @@ -35,6 +35,7 @@ type ManifestEntry struct { // Manifest holds the list of managed plugins. type Manifest struct { + // InitURL stores the init manifest URL or absolute local path. InitURL string `yaml:"init_url,omitempty"` Plugins []ManifestEntry `yaml:"plugins"` }