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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ syspi init [-C dir] [-publisher P] [-name N] [-version V] write .project.json
syspi add <resource>[@<version>] [<constraint>] 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] <dir> deterministic .kpar; checks tag == version in a tagged checkout
```

Expand Down
1 change: 1 addition & 0 deletions changes/unreleased/list.added.md
Original file line number Diff line number Diff line change
@@ -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/`.
49 changes: 49 additions & 0 deletions cmd/syspi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ package main

import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"text/tabwriter"
"time"

"github.com/Open-MBEE/SysPI/internal/install"
Expand All @@ -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

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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))
Expand Down
7 changes: 7 additions & 0 deletions docs/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ lib/
The directory name is `<publisher>-<name>_<version>` (`<name>_<version>` 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
Expand Down
46 changes: 46 additions & 0 deletions internal/install/libpath.go
Original file line number Diff line number Diff line change
@@ -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)
}
76 changes: 76 additions & 0 deletions internal/install/list.go
Original file line number Diff line number Diff line change
@@ -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"
}
118 changes: 118 additions & 0 deletions internal/install/list_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading