diff --git a/README.md b/README.md index 8ff2225..ebe74dd 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ cmd, _ = translator.BuildCommand("bundler", "add", managers.CommandInput{ | renv | cran | renv.lock | | helm | helm | Chart.lock | | brew | homebrew | - | +| apm | apm | apm.lock.yaml, apm.lock | Most managers support: install, add, remove, list, outdated, update, resolve. Some also support init, vendor and path. Some managers (maven, gradle, sbt, lein) have limited CLI support for add/remove operations. @@ -271,7 +272,7 @@ result, _ := manager.Init(ctx) Only managers that can scaffold in-place are supported. Managers whose `new` command always creates a child directory (mix, lein, helm, stack, pub, rebar3) or whose `init` command bootstraps global state rather than a project (opam) are excluded. -**Managers with init support:** npm, pnpm, yarn, bun, bundler, cargo, gomod, uv, poetry, deno, composer, swift, nuget, gradle, conan, conda, cabal, nimble, luarocks, shards, vcpkg, renv +**Managers with init support:** npm, pnpm, yarn, bun, bundler, cargo, gomod, uv, poetry, deno, composer, swift, nuget, gradle, conan, conda, cabal, nimble, luarocks, shards, vcpkg, renv, apm ### Getting package paths @@ -309,7 +310,7 @@ result, _ := manager.Resolve(ctx) fmt.Println(result.Stdout) // raw CLI output (JSON tree, text tree, etc.) ``` -**Managers with resolve support:** npm, pnpm, yarn, bun, bundler, cargo, gomod, pip, uv, poetry, conda, composer, maven, gradle, lein, swift, deno, stack, pub, mix, rebar3, nuget, conan, helm, renv +**Managers with resolve support:** npm, pnpm, yarn, bun, bundler, cargo, gomod, pip, uv, poetry, conda, composer, maven, gradle, lein, swift, deno, stack, pub, mix, rebar3, nuget, conan, helm, renv, apm ### Replacing a dependency diff --git a/apm_test.go b/apm_test.go new file mode 100644 index 0000000..cc1ea34 --- /dev/null +++ b/apm_test.go @@ -0,0 +1,158 @@ +package managers + +import ( + "reflect" + "slices" + "testing" + "testing/fstest" + + "github.com/git-pkgs/managers/definitions" +) + +func TestAPMCommands(t *testing.T) { + tr := loadTranslator(t) + tests := []struct { + name string + operation string + input CommandInput + want []string + }{ + {"init", "init", CommandInput{}, []string{"apm", "init", "--yes"}}, + {"install", "install", CommandInput{}, []string{"apm", "install"}}, + {"frozen install", "install", CommandInput{Flags: map[string]any{"frozen": true}}, []string{"apm", "install", "--frozen"}}, + {"add", "add", CommandInput{Args: map[string]string{"package": "owner/repo"}}, []string{"apm", "install", "owner/repo"}}, + {"add with ref", "add", CommandInput{Args: map[string]string{"package": "owner/repo", "version": "v1.2.0"}}, []string{"apm", "install", "owner/repo#v1.2.0"}}, + {"add dev", "add", CommandInput{ + Args: map[string]string{"package": "owner/repo", "version": "main"}, + Flags: map[string]any{"dev": true}, + }, []string{"apm", "install", "owner/repo#main", "--dev"}}, + {"remove", "remove", CommandInput{Args: map[string]string{"package": "owner/repo"}}, []string{"apm", "uninstall", "owner/repo"}}, + {"list", "list", CommandInput{}, []string{"apm", "deps", "list"}}, + {"outdated", "outdated", CommandInput{}, []string{"apm", "outdated"}}, + {"update all", "update", CommandInput{}, []string{"apm", "update", "--yes"}}, + {"update one", "update", CommandInput{Args: map[string]string{"package": "owner/repo"}}, []string{"apm", "update", "owner/repo", "--yes"}}, + {"resolve", "resolve", CommandInput{}, []string{"apm", "deps", "tree"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tr.BuildCommand("apm", tt.operation, tt.input) + if err != nil { + t.Fatalf("BuildCommand failed: %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestAPMAddRequiresPackage(t *testing.T) { + if _, err := loadTranslator(t).BuildCommand("apm", "add", CommandInput{}); err == nil { + t.Error("expected an error without a package") + } +} + +func TestValidateAPMPackage(t *testing.T) { + for _, name := range []string{"owner/repo", "gitlab.com/group/sub/repo", "git@github.com:owner/repo.git", "../local", "plugin@marketplace"} { + if err := ValidatePackageName("apm_package", name); err != nil { + t.Errorf("%q rejected: %v", name, err) + } + } + for _, name := range []string{"", "-", "--force", "-g"} { + if err := ValidatePackageName("apm_package", name); err == nil { + t.Errorf("%q accepted", name) + } + } +} + +func TestAPMRejectsFlagPackages(t *testing.T) { + tr := loadTranslator(t) + for _, op := range []string{"add", "remove", "update"} { + if _, err := tr.BuildCommand("apm", op, CommandInput{Args: map[string]string{"package": "--force"}}); err == nil { + t.Errorf("%s accepted a flag as the package", op) + } + } + for _, pkg := range []string{"owner/repo", "https://github.com/owner/repo.git", "./skills/local", "plugin@marketplace"} { + if _, err := tr.BuildCommand("apm", "add", CommandInput{Args: map[string]string{"package": pkg}}); err != nil { + t.Errorf("add rejected %q: %v", pkg, err) + } + } +} + +func TestAPMCapabilities(t *testing.T) { + defs, err := definitions.LoadEmbedded() + if err != nil { + t.Fatal(err) + } + i := slices.IndexFunc(defs, func(d *definitions.Definition) bool { return d.Name == "apm" }) + if i < 0 { + t.Fatal("apm definition not embedded") + } + m := &GenericManager{def: defs[i]} + for _, c := range []Capability{CapInit, CapInstall, CapInstallFrozen, CapAdd, CapAddDev, CapRemove, CapUpdate, CapList, CapOutdated, CapResolve} { + if !m.Supports(c) { + t.Errorf("apm should support %s", c) + } + } + // No APM dependency command has JSON output. + for _, c := range []Capability{CapJSONOutput, CapPath, CapVendor} { + if m.Supports(c) { + t.Errorf("apm should not support %s", c) + } + } +} + +func TestAPMDetectFS(t *testing.T) { + detector := newEmbeddedDetector(t) + tests := []struct { + name string + project fstest.MapFS + want FileDetection + }{ + {"manifest only", fstest.MapFS{ + "apm.yml": {Data: []byte("name: app\n")}, + }, FileDetection{Manager: "apm", Ecosystem: "apm", Directory: ".", Manifest: "apm.yml"}}, + {"current lockfile", fstest.MapFS{ + "apm.yml": {Data: []byte("name: app\n")}, + "apm.lock.yaml": {Data: []byte("lockfile_version: \"2\"\n")}, + }, FileDetection{Manager: "apm", Ecosystem: "apm", Directory: ".", Manifest: "apm.yml", Lockfile: "apm.lock.yaml"}}, + {"legacy lockfile", fstest.MapFS{ + "apm.yml": {Data: []byte("name: app\n")}, + "apm.lock": {Data: []byte("lockfile_version: \"1\"\n")}, + }, FileDetection{Manager: "apm", Ecosystem: "apm", Directory: ".", Manifest: "apm.yml", Lockfile: "apm.lock"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, policy := range []*FileDetectionPolicy{nil, GitPkgsFileDetectionPolicy()} { + got, err := detector.DetectFS(tt.project, ".", FileDetectOptions{Policy: policy}) + if err != nil { + t.Fatalf("DetectFS: %v", err) + } + if got != tt.want { + t.Errorf("DetectFS (policy %v) = %#v, want %#v", policy != nil, got, tt.want) + } + } + }) + } +} + +func TestAPMDetectAllFSWithPython(t *testing.T) { + detector := newEmbeddedDetector(t) + project := fstest.MapFS{ + "pyproject.toml": {Data: []byte("[project]\nname = 'app'\n")}, + "apm.yml": {Data: []byte("name: app\n")}, + } + want := []FileDetection{ + {Manager: "uv", Ecosystem: "pypi", Directory: ".", Manifest: "pyproject.toml"}, + {Manager: "apm", Ecosystem: "apm", Directory: ".", Manifest: "apm.yml"}, + } + for _, policy := range []*FileDetectionPolicy{nil, GitPkgsFileDetectionPolicy()} { + got, err := detector.DetectAllFS(project, ".", FileDetectOptions{Policy: policy}) + if err != nil { + t.Fatalf("DetectAllFS: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("DetectAllFS (policy %v) = %#v, want %#v", policy != nil, got, want) + } + } +} diff --git a/definitions/apm.yaml b/definitions/apm.yaml new file mode 100644 index 0000000..484f917 --- /dev/null +++ b/definitions/apm.yaml @@ -0,0 +1,106 @@ +# APM - Microsoft Agent Package Manager +# https://github.com/microsoft/apm +# +# APM uses apm.yml for manifests and apm.lock.yaml for lockfiles (apm.lock +# before that). Packages are Git repositories (owner/repo) with an optional +# #ref. The top-level "list" command lists scripts, so dependencies are +# listed with "deps list". No dependency command has JSON output. + +name: apm +ecosystem: apm +binary: apm +version: ">=0.35.1" +status: current +min_tested: "0.35.1" +max_tested: "0.35.1" + +detection: + lockfiles: + - apm.lock.yaml + - apm.lock + manifests: + - apm.yml + default: true + ecosystem_order: 280 + priority: 10 + +version_detection: + command: [--version] + pattern: 'version (\d+\.\d+\.\d+)' + +commands: + init: + base: [init] + default_flags: [--yes] + exit_codes: + 0: success + 1: error + + install: + base: [install] + flags: + frozen: [--frozen] + exit_codes: + 0: success + 1: error + + # apm install owner/repo#ref adds the package to apm.yml + add: + base: [install] + args: + package: {position: 0, required: true, validate: apm_package} + version: {position: 0, suffix: "#", required: false} + flags: + dev: [--dev] + exit_codes: + 0: success + 1: error + + remove: + base: [uninstall] + args: + package: {position: 0, required: true, validate: apm_package} + exit_codes: + 0: success + 1: error + + list: + base: [deps, list] + exit_codes: + 0: success + 1: error + + # exits 0 whether or not anything is outdated + outdated: + base: [outdated] + exit_codes: + 0: success + 1: error + + # --yes skips the confirmation prompt, which fails without a TTY + update: + base: [update] + args: + package: {position: 0, required: false, validate: apm_package} + default_flags: [--yes] + exit_codes: + 0: success + 1: error + + resolve: + base: [deps, tree] + exit_codes: + 0: success + 1: error + +capabilities: + - init + - install + - install_frozen + - add + - add_dev + - remove + - update + - list + - outdated + - resolve diff --git a/detection_policy.go b/detection_policy.go index 9c80cbf..c1d36cd 100644 --- a/detection_policy.go +++ b/detection_policy.go @@ -1,6 +1,7 @@ package managers const ( + apmManagerName = "apm" npmManagerName = "npm" nugetManagerName = "nuget" ) @@ -93,6 +94,15 @@ func GitPkgsFileDetectionPolicy() *FileDetectionPolicy { fileDetectionPolicy("haxelib", "haxelib", "haxelib.json", ""), fileDetectionPolicy("nix", "nix", "flake.nix", "flake.lock"), fileDetectionPolicy("conda", "conda", "environment.yml", ""), + { + Ecosystem: apmManagerName, + DefaultManager: apmManagerName, + Manifests: []string{"apm.yml"}, + Lockfiles: []LockfileDetectionPolicy{ + {File: "apm.lock.yaml", Manager: apmManagerName}, + {File: "apm.lock", Manager: apmManagerName}, + }, + }, }} } diff --git a/policy.go b/policy.go index 8ea2c8b..f5c8e83 100644 --- a/policy.go +++ b/policy.go @@ -263,17 +263,22 @@ func (p PackageBlocklistPolicy) Check(ctx context.Context, op *PolicyOperation) } // match checks pkg against the blocklist, both as given and with any -// trailing @version stripped so that a versioned add such as -// `go get example.com/foo@v1.2.3` or `npm install @scope/name@1.0.0` -// is caught by an entry keyed on the bare package name. +// trailing #ref or @version stripped so that a versioned add such as +// `go get example.com/foo@v1.2.3`, `npm install @scope/name@1.0.0` or +// `apm install owner/repo#main` is caught by an entry keyed on the bare +// package name. func (p PackageBlocklistPolicy) match(pkg string) *PolicyResult { - if reason, blocked := p.Blocked[pkg]; blocked { - return blockedResult(pkg, reason) + candidates := []string{pkg} + if i := strings.LastIndex(pkg, "#"); i > 0 { + pkg = pkg[:i] + candidates = append(candidates, pkg) } if i := strings.LastIndex(pkg, "@"); i > 0 { - bare := pkg[:i] - if reason, blocked := p.Blocked[bare]; blocked { - return blockedResult(bare, reason) + candidates = append(candidates, pkg[:i]) + } + for _, name := range candidates { + if reason, blocked := p.Blocked[name]; blocked { + return blockedResult(name, reason) } } return nil diff --git a/policy_test.go b/policy_test.go index 381a53d..4e45426 100644 --- a/policy_test.go +++ b/policy_test.go @@ -175,6 +175,7 @@ func TestPackageBlocklistScopedAndVersioned(t *testing.T) { Blocked: map[string]string{ "@scope/name": "scoped bare", "github.com/org/mod": "go module", + "owner/repo": "apm package", }, } cases := []struct { @@ -187,6 +188,9 @@ func TestPackageBlocklistScopedAndVersioned(t *testing.T) { {"github.com/org/mod@v1.2.3", false}, {"github.com/org/mod", false}, {"github.com/org/other@v1.0.0", true}, + {"owner/repo#main", false}, + {"owner/repo#v1.2.0", false}, + {"owner/other#main", true}, } for _, tc := range cases { res, _ := policy.Check(context.Background(), &PolicyOperation{Packages: []string{tc.pkg}}) diff --git a/references/apm/deps-list.txt b/references/apm/deps-list.txt new file mode 100644 index 0000000..38a17ec --- /dev/null +++ b/references/apm/deps-list.txt @@ -0,0 +1,9 @@ +Usage: apm deps list [OPTIONS] + + List installed APM dependencies and their primitives + +Options: + -g, --global List user-scope dependencies (~/.apm/) instead of project + --all Show both project and user-scope dependencies + --insecure Show only installed dependencies locked to http:// sources + --help Show this message and exit. diff --git a/references/apm/deps-tree.txt b/references/apm/deps-tree.txt new file mode 100644 index 0000000..dae4a89 --- /dev/null +++ b/references/apm/deps-tree.txt @@ -0,0 +1,7 @@ +Usage: apm deps tree [OPTIONS] + + Show the full dependency tree + +Options: + -g, --global Show user-scope dependency tree (~/.apm/) + --help Show this message and exit. diff --git a/references/apm/deps.txt b/references/apm/deps.txt new file mode 100644 index 0000000..711539c --- /dev/null +++ b/references/apm/deps.txt @@ -0,0 +1,14 @@ +Usage: apm deps [OPTIONS] COMMAND [ARGS]... + + Manage APM package dependencies + +Options: + --help Show this message and exit. + +Commands: + clean Remove all APM dependencies + info Show detailed package information (alias for 'apm view PACKAGE'... + list List installed APM dependencies and their primitives + tree Show the full dependency tree + update DEPRECATED: use 'apm update' instead (strict superset). + why Explain why a package is installed by walking the lockfile back... diff --git a/references/apm/help.txt b/references/apm/help.txt new file mode 100644 index 0000000..436e9f6 --- /dev/null +++ b/references/apm/help.txt @@ -0,0 +1,57 @@ +Usage: apm [OPTIONS] COMMAND [ARGS]... + + Agent Package Manager (APM): The package manager for AI-Native Development + +Options: + --version Show version and exit. + -v, --verbose Enable debug-level logging (equivalent to + APM_LOG_LEVEL=DEBUG). + --help Show this message and exit. + +Commands: + approve Approve executable primitives (hooks, MCP, LSP, bin,... + audit Scan installed primitives for hidden Unicode, drift, and... + auth Get a working credential for a git host. + cache Manage the local package cache + compile Compile APM context into distributed AGENTS.md files + config Configure APM CLI. + deny Deny executable primitives for packages (a narrowing... + deps Manage APM package dependencies + doctor Run environment diagnostics (git, network, auth,... + experimental Manage experimental feature flags + find Trace a materialized file back to its contributing... + init Initialize a new APM project + install Install APM, MCP, and LSP dependencies (supports APM... + lifecycle Inspect, test, and scaffold lifecycle scripts. + list List available scripts in the current project + lock Resolve dependencies and write apm.lock.yaml without... + marketplace Manage marketplaces for discovery and governance + mcp Discover, inspect, and install MCP servers + outdated Show outdated locked dependencies + pack Pack distributable artifacts from your APM project. + plugin Scaffold and manage plugins (plugin-author workflows) + policy Inspect and diagnose APM policy + preview Preview a script's compiled prompt files + prune Remove APM packages absent from the resolved dependency... + publish Publish a package to a registry. + run Run a script with parameters (experimental) + runtime Manage AI runtimes (experimental) + search Search plugins in a marketplace (QUERY@MARKETPLACE) + self-update Update the APM CLI binary itself to the latest version. + targets Show resolved targets for the current project. + uninstall Remove packages using manifest entries or direct locked... + unpack [Deprecated] Extract an APM bundle into the current project. + update Refresh APM dependencies to the latest matching refs + view View package metadata or list remote versions + + Common workflows: + apm auth Set up credentials for a git host + apm init Scaffold a new project + apm install Install dependencies from apm.yml + apm install --frozen Reproduce lockfile exactly (CI-safe) + apm lock Resolve deps only (no deploy/delete) + apm outdated See what's drifted from upstream + apm update Refresh refs and rewrite the lockfile + apm audit --ci Validate lockfile integrity for CI gates + apm doctor Diagnose environment problems + apm run