Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
158 changes: 158 additions & 0 deletions apm_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
106 changes: 106 additions & 0 deletions definitions/apm.yaml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions detection_policy.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package managers

const (
apmManagerName = "apm"
npmManagerName = "npm"
nugetManagerName = "nuget"
)
Expand Down Expand Up @@ -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},
},
},
}}
}

Expand Down
21 changes: 13 additions & 8 deletions policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}})
Expand Down
9 changes: 9 additions & 0 deletions references/apm/deps-list.txt
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions references/apm/deps-tree.txt
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions references/apm/deps.txt
Original file line number Diff line number Diff line change
@@ -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...
Loading