diff --git a/README.md b/README.md index da22cf2..6119d3d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ syspi init [-C dir] [-publisher P] [-name N] [-version V] write .project.json syspi add [@] [] add a usage, resolve, install, lock syspi install | sync resolve .project.json + lock, install what is missing or changed syspi lock write .project.lock.json without installing +syspi list [-json] the locked packages, where each is in lib/ and whether it is present syspi bundle [-o out.kpar] [-tag T] deterministic .kpar; checks tag == version in a tagged checkout ``` diff --git a/changes/unreleased/list.added.md b/changes/unreleased/list.added.md new file mode 100644 index 0000000..51c5ffa --- /dev/null +++ b/changes/unreleased/list.added.md @@ -0,0 +1 @@ +- `syspi list [-json]` prints the locked packages in resource order with version, kind or VCS, revision, path under `lib/` and whether that installed copy is present; standard libraries and local directories are listed as recorded but never installed. It reads only the lock and `lib/`. diff --git a/cmd/syspi/main.go b/cmd/syspi/main.go index 2ee2eb2..7f65580 100644 --- a/cmd/syspi/main.go +++ b/cmd/syspi/main.go @@ -6,6 +6,7 @@ package main import ( "context" + "encoding/json" "errors" "flag" "fmt" @@ -13,6 +14,7 @@ import ( "os" "os/signal" "path/filepath" + "text/tabwriter" "time" "github.com/Open-MBEE/SysPI/internal/install" @@ -36,6 +38,7 @@ Commands: install resolve .project.json and the lock, install what is missing or changed sync same as install lock resolve and write .project.lock.json without installing + list print the locked packages and whether each installed copy is present bundle build a deterministic .kpar from a project directory version print the version @@ -69,6 +72,8 @@ func run(args []string, stdout, stderr io.Writer) int { err = cmdInstall(ctx, args[0], args[1:], stdout, stderr, false) case "lock": err = cmdInstall(ctx, args[0], args[1:], stdout, stderr, true) + case "list": + err = cmdList(args[1:], stdout, stderr) case "bundle": err = cmdBundle(ctx, args[1:], stdout, stderr) case "version", "-version", "--version": @@ -259,6 +264,50 @@ func cmdInstall(ctx context.Context, name string, args []string, stdout, stderr return nil } +func cmdList(args []string, stdout, stderr io.Writer) error { + var c commonFlags + fs := newFlagSet("list", stderr, "[flags]") + c.bind(fs) + asJSON := fs.Bool("json", false, "print the listing as a JSON array") + if err := parse(fs, args); err != nil { + return err + } + if fs.NArg() != 0 { + fs.Usage() + return errUsage + } + ls, err := install.List(c.dir) + if err != nil { + return err + } + if *asJSON { + enc := json.NewEncoder(stdout) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(ls) + } + tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "RESOURCE\tVERSION\tKIND\tREVISION\tPATH\tSTATE") + for i := range ls { + l := &ls[i] + kind, rev, path := l.Kind, l.Revision, l.Path + if l.Kind == "vcs" { + kind = l.VCS + } + for _, p := range []*string{&rev, &path} { + if *p == "" { + *p = "-" + } + } + ver := l.Version + if ver == "" { + ver = "-" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", l.Resource, ver, kind, rev, path, l.State()) + } + return tw.Flush() +} + func report(w io.Writer, res *install.Result) { n := len(res.Lock.Packages) fmt.Fprintf(w, "%d package(s) locked: %d installed, %d already present, %d not installed (standard libraries and local directories)", n, len(res.Installed), len(res.Reused), len(res.Skipped)) diff --git a/docs/packages.md b/docs/packages.md index 09daf1a..23690bc 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -144,6 +144,13 @@ lib/ The directory name is `-_` (`_` without a publisher), the convention OpenSysML reads. +`syspi list` prints the lock in resource order — one line per package with its version, kind +(the VCS of an exported tree), revision, path under `lib/` and whether that copy is present — +and `syspi list -json` the same as an array of objects (`resource`, `kind`, `publisher`, `name`, +`version`, `vcs`, `tag`, `revision`, `source`, `path`, `installed`). Standard libraries and local +directories have no path: they are recorded, never installed. `list` reads only the lock and +`lib/`; it never contacts a repository. + ## Bundle reproducibility `syspi bundle` writes members sorted with fixed 1980-01-01 ZIP timestamps and regenerates diff --git a/internal/install/libpath.go b/internal/install/libpath.go new file mode 100644 index 0000000..aac9cf4 --- /dev/null +++ b/internal/install/libpath.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "path/filepath" + "strings" + + "github.com/Open-MBEE/SysPI/internal/digest" + "github.com/Open-MBEE/SysPI/internal/lock" + "github.com/Open-MBEE/SysPI/internal/project" +) + +// libPath is where under libDir the lock entry's installed copy lives: the +// exported tree of a vcs entry, the .kpar of an archive entry; "" for a +// package that is never installed (a local directory, a standard library) +// or whose name would not be a direct child of libDir. +func libPath(libDir string, e *lock.Entry) string { + stem := (&project.Project{Name: e.Name, Publisher: e.Publisher, Version: e.Version}).InstallName() + if stem == "." || stem == ".." || strings.ContainsAny(stem, `/\`) { + return "" + } + var p string + switch e.Kind { + case lock.KindVCS: + p = filepath.Join(libDir, stem) + case lock.KindArchive: + p = filepath.Join(libDir, stem+project.ArchiveExt) + default: + return "" + } + if filepath.Dir(p) != filepath.Clean(libDir) { + return "" + } + return p +} + +// installedMatches reports whether the copy at p is the one the lock entry +// describes: the locked tree digest, or the locked .kpar digest. +func installedMatches(p string, e *lock.Entry) bool { + if e.Kind == lock.KindArchive { + cur, err := digest.File(p) + return err == nil && cur == e.SHA256 + } + return installedTreeMatches(p, e) +} diff --git a/internal/install/list.go b/internal/install/list.go new file mode 100644 index 0000000..648d304 --- /dev/null +++ b/internal/install/list.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/Open-MBEE/SysPI/internal/lock" + "github.com/Open-MBEE/SysPI/internal/project" +) + +// Listing is one locked package as list reports it. +type Listing struct { + Resource string `json:"resource"` + Kind string `json:"kind"` + Publisher string `json:"publisher,omitempty"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + VCS string `json:"vcs,omitempty"` + Tag string `json:"tag,omitempty"` + Revision string `json:"revision,omitempty"` + // Source is the URL or path an archive came from, or the local directory read in place. + Source string `json:"source,omitempty"` + // Path is the installed copy under lib/, relative to the project root; + // empty for a package that is never installed (standard, local). + Path string `json:"path,omitempty"` + // Installed says whether the copy at Path is present; nil without a Path. + Installed *bool `json:"installed,omitempty"` +} + +// List reads the lock of the project at root and describes each locked +// package in resource order. It reads nothing but the lock and lib/. +func List(root string) ([]Listing, error) { + l, ok, err := lock.Read(root, project.LockFile) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("%s: no %s; run syspi lock or syspi install first", root, project.LockFile) + } + sort.Slice(l.Packages, func(i, j int) bool { return l.Packages[i].Resource < l.Packages[j].Resource }) + libDir := filepath.Join(root, project.LibDir) + out := make([]Listing, 0, len(l.Packages)) + for i := range l.Packages { + e := &l.Packages[i] + li := Listing{Resource: e.Resource, Kind: e.Kind, Publisher: e.Publisher, Name: e.Name, Version: e.Version, VCS: e.VCS, Tag: e.Tag, Revision: e.Revision} + if e.Kind != lock.KindVCS { + li.Source = e.Source + } + if p := libPath(libDir, e); p != "" { + li.Path = rel(root, p) + _, err := os.Lstat(p) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + present := err == nil + li.Installed = &present + } + out = append(out, li) + } + return out, nil +} + +// State is the one word the text listing prints for a package's installed copy. +func (l *Listing) State() string { + switch { + case l.Installed == nil: + return "not installed" + case *l.Installed: + return "present" + } + return "missing" +} diff --git a/internal/install/list_test.go b/internal/install/list_test.go new file mode 100644 index 0000000..95cea13 --- /dev/null +++ b/internal/install/list_test.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Open-MBEE/SysPI/internal/project" +) + +func TestList(t *testing.T) { + widgets, _ := fixture(t) + wres := "git+file://" + filepath.ToSlash(widgets) + pkg := t.TempDir() + write(t, pkg, ".project.json", `{"name":"tools","publisher":"acme","version":"3.1.0"}`) + kpar := filepath.Join(t.TempDir(), "acme-tools_3.1.0.kpar") + if _, err := project.Bundle(pkg, kpar, time.Unix(0, 0)); err != nil { + t.Fatal(err) + } + editable := t.TempDir() + write(t, editable, ".project.json", `{"name":"dev","version":"0.0.1"}`) + std := "https://www.omg.org/spec/SysML/20250201/Systems-Library.kpar" + dir := consumer(t, + project.Usage{Resource: wres, VersionConstraint: "~1.1.0"}, + project.Usage{Resource: kpar}, + project.Usage{Resource: editable}, + project.Usage{Resource: std}, + ) + if _, err := List(dir); err == nil || !strings.Contains(err.Error(), "no "+project.LockFile) { + t.Fatalf("list without a lock: %v", err) + } + run(t, dir, Options{}) + + ls, err := List(dir) + if err != nil { + t.Fatal(err) + } + var got []string + for i := range ls { + l := &ls[i] + got = append(got, l.Kind+" "+l.Version+" "+l.Path+" "+l.State()) + } + // Resource order: the base repository (git+file:///…/base.git) sorts + // before widgets; the standard IRI and the absolute paths sort by their text. + want := map[string]bool{ + "vcs 2.1.0 lib/base_2.1.0 present": true, + "vcs 1.1.0 lib/acme-widgets_1.1.0 present": true, + "archive 3.1.0 lib/acme-tools_3.1.0.kpar present": true, + "local 0.0.1 not installed": true, + "standard not installed": true, + } + if len(got) != len(want) { + t.Fatalf("listing: %q", got) + } + for _, g := range got { + if !want[g] { + t.Errorf("unexpected line %q", g) + } + } + for i := 1; i < len(ls); i++ { + if ls[i-1].Resource >= ls[i].Resource { + t.Fatalf("not in resource order: %q >= %q", ls[i-1].Resource, ls[i].Resource) + } + } + for i := range ls { + switch ls[i].Kind { + case "vcs": + if ls[i].VCS != "git" || ls[i].Revision == "" || ls[i].Source != "" { + t.Errorf("vcs listing %+v", ls[i]) + } + case "archive": + if ls[i].Source != kpar { + t.Errorf("archive listing %+v", ls[i]) + } + case "local": + if ls[i].Source != editable || ls[i].Installed != nil { + t.Errorf("local listing %+v", ls[i]) + } + case "standard": + if ls[i].Resource != std || ls[i].Installed != nil { + t.Errorf("standard listing %+v", ls[i]) + } + } + } + + // A copy that cannot be looked at is an error, not "missing" (file modes + // do not deny a stat on Windows, nor to root). + if runtime.GOOS != "windows" && os.Getuid() != 0 { + os.Chmod(filepath.Join(dir, "lib"), 0o000) + _, err = List(dir) + os.Chmod(filepath.Join(dir, "lib"), 0o755) + if err == nil { + t.Fatal("unreadable lib/ listed without an error") + } + } + + // A copy that is gone is reported missing, not an error. + os.RemoveAll(filepath.Join(dir, "lib", "acme-widgets_1.1.0")) + os.Remove(filepath.Join(dir, "lib", "acme-tools_3.1.0.kpar")) + ls, err = List(dir) + if err != nil { + t.Fatal(err) + } + var missing []string + for i := range ls { + if ls[i].State() == "missing" { + missing = append(missing, ls[i].Path) + } + } + if strings.Join(missing, ",") != "lib/acme-tools_3.1.0.kpar,lib/acme-widgets_1.1.0" { + t.Fatalf("missing: %q", missing) + } +} diff --git a/tests/e2e/list_test.go b/tests/e2e/list_test.go new file mode 100644 index 0000000..3fab6c0 --- /dev/null +++ b/tests/e2e/list_test.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestList(t *testing.T) { + bin := syspiBinary(t) + root := t.TempDir() + gitRes := "git+file://" + filepath.ToSlash(gitFixture(t, root)) + std := "https://www.omg.org/spec/SysML/20250201/Systems-Library.kpar" + + app := filepath.Join(root, "app") + os.MkdirAll(app, 0o755) + sh(t, app, bin, "init", "-name", "app") + if out, err := shErr(app, nil, bin, "list"); err == nil || !strings.Contains(out, "no .project.lock.json") { + t.Fatalf("list before lock: %v\n%s", err, out) + } + sh(t, app, bin, "add", gitRes+"@1.0.0") + sh(t, app, bin, "add", gitRes+"#packages/gears", "~1.1.0") + sh(t, app, bin, "add", std) + + out := sh(t, app, bin, "list", "-offline") + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 4 || !strings.HasPrefix(lines[0], "RESOURCE") { + t.Fatalf("list:\n%s", out) + } + // Resource order: the standard IRI, then widgets' identity, then gears' (its subpath sorts after). + for i, want := range []string{ + std + " - standard - - not installed", + "pkg:generic/widgets?vcs_url= 1.0.0 git * lib/acme-widgets_1.0.0 present", + "pkg:generic/widgets?vcs_url= %23packages%2Fgears 1.1.0 git * lib/acme-gears_1.1.0 present", + } { + if got := fields(lines[1+i]); got != want { + t.Errorf("line %d: %q, want %q", 1+i, got, want) + } + } + if sh(t, app, bin, "list") != out { + t.Error("list is not deterministic") + } + + var ls []map[string]any + if err := json.Unmarshal([]byte(sh(t, app, bin, "list", "-json")), &ls); err != nil { + t.Fatal(err) + } + if len(ls) != 3 || ls[0]["kind"] != "standard" || ls[0]["installed"] != nil || ls[2]["path"] != "lib/acme-gears_1.1.0" || ls[2]["installed"] != true || ls[2]["vcs"] != "git" || ls[2]["version"] != "1.1.0" || ls[2]["revision"] == "" { + t.Fatalf("json: %v", ls) + } + + // A copy that is gone shows as missing; the lock is untouched. + lockBefore, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")) + os.RemoveAll(filepath.Join(app, "lib", "acme-gears_1.1.0")) + out = sh(t, app, bin, "list") + if lines = strings.Split(strings.TrimRight(out, "\n"), "\n"); !strings.HasSuffix(fields(lines[2]), "lib/acme-widgets_1.0.0 present") || !strings.HasSuffix(fields(lines[3]), "lib/acme-gears_1.1.0 missing") { + t.Fatalf("after removal:\n%s", out) + } + if lockAfter, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")); string(lockAfter) != string(lockBefore) { + t.Error("list wrote the lock") + } + if out, err := shErr(app, nil, bin, "list", "extra"); err == nil || !strings.Contains(out, "Usage: syspi list") { + t.Fatalf("list with an argument: %v\n%s", err, out) + } +} + +// fields is a listing line with its columns one space apart: the resource with +// its percent-encoded parts split out, and a 40-hex revision replaced by "*". +func fields(line string) string { + var out []string + for _, f := range strings.Fields(line) { + if len(f) == 40 && strings.Trim(f, "0123456789abcdef") == "" { + f = "*" + } + if strings.HasPrefix(f, "pkg:generic/") { + id, rest, _ := strings.Cut(f, "vcs_url=") + f = id + "vcs_url=" + if i := strings.Index(rest, "%23"); i >= 0 { + f += " " + rest[i:] + } + } + out = append(out, f) + } + return strings.Join(out, " ") +} + +func TestListAcrossVCS(t *testing.T) { + requireOrSkip(t, "hg", "svn", "svnadmin") + bin := syspiBinary(t) + root := t.TempDir() + for name, res := range map[string]string{ + "git": "git+file://" + filepath.ToSlash(gitFixture(t, root)), + "hg": "hg+file://" + filepath.ToSlash(hgFixture(t, root)), + "svn": "svn+file://" + strings.TrimPrefix(svnFixture(t, root), "file://"), + } { + app := filepath.Join(root, "app-"+name) + os.MkdirAll(app, 0o755) + sh(t, app, bin, "init", "-name", "app") + sh(t, app, bin, "add", res+"@1.1.0") + out := sh(t, app, bin, "list") + if got := fields(strings.Split(strings.TrimSpace(out), "\n")[1]); !strings.Contains(got, " 1.1.0 "+name+" ") || !strings.HasSuffix(got, " lib/acme-widgets_1.1.0 present") { + t.Errorf("%s:\n%s", name, out) + } + } +}