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: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ what package managers install.
- **Lock** — `.project.lock.json` beside `.project.json`: one entry per package of the
transitive closure with `resource`, `kind`, `vcs`, `source`, `tag`, `revision`, `sha256` and
the usages it declares; deterministic bytes, so a fresh clone reproduces it exactly. A lock
entry wins over a constraint.
entry wins over a constraint everywhere but in `update`, the one command that moves a lock
entry (`outdated` shows what it would move).
- **Constraints** — `versionConstraint` in `.project.json`: exact (`1.2.0`), caret (`^1.2.0`),
tilde (`~1.2.0`) and comparison ranges (`>=1.2.0 <2.0.0`). `@<version>` in the resource with no
`versionConstraint` is an exact constraint.
Expand All @@ -55,6 +56,8 @@ are in [docs/packages.md](docs/packages.md).
syspi init [-C dir] [-publisher P] [-name N] [-version V] write .project.json and .meta.json
syspi add <resource>[@<version>] [<constraint>] add a usage, resolve, install, lock
syspi remove <resource>... drop a usage, re-lock, remove what nothing else needs from lib/
syspi update [<resource>...] move the named (or all) usages to the highest version allowed, install, re-lock
syspi outdated [-json] [<resource>...] report what update would move; write nothing
syspi install | sync resolve .project.json + lock, install what is missing or changed
syspi lock write .project.lock.json without installing
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/update.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `syspi update [<resource>…]` re-resolves the named (or all) usages to the highest version their constraints allow, ignoring the lock — the one command that moves a lock entry — installs, rewrites the lock and removes the copies the moved entries left under `lib/`; `syspi outdated [-json] [<resource>…]` reports what `update` would move (`name old -> new`) and writes nothing.
61 changes: 61 additions & 0 deletions cmd/syspi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package main

import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
Expand Down Expand Up @@ -34,6 +35,8 @@ Commands:
init write .project.json and .meta.json for a new package
add add a usage to .project.json, then resolve, install and lock
remove drop a usage from .project.json, re-lock, remove what nothing else needs from lib/
update move the named (or all) usages to the highest version their constraints allow, install, re-lock
outdated report what update would move; write nothing
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
Expand Down Expand Up @@ -68,6 +71,10 @@ func run(args []string, stdout, stderr io.Writer) int {
err = cmdAdd(ctx, args[1:], stdout, stderr)
case "remove":
err = cmdRemove(ctx, args[1:], stdout, stderr)
case "update":
err = cmdUpdate(ctx, args[1:], stdout, stderr)
case "outdated":
err = cmdOutdated(ctx, args[1:], stdout, stderr)
case "install", "sync":
err = cmdInstall(ctx, args[0], args[1:], stdout, stderr, false)
case "lock":
Expand Down Expand Up @@ -268,6 +275,60 @@ func cmdRemove(ctx context.Context, args []string, stdout, stderr io.Writer) err
return refreshMeta(c.dir)
}

func cmdUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) error {
var c commonFlags
fs := newFlagSet("update", stderr, "[flags] [<resource>...]")
c.bind(fs)
if err := parse(fs, args); err != nil {
return err
}
res, err := install.Update(ctx, c.options(stderr, false), fs.Args())
if err != nil {
return err
}
for i := range res.Changes {
fmt.Fprintln(stdout, res.Changes[i].Describe())
}
if len(res.Changes) == 0 {
fmt.Fprintln(stdout, "every package is at the highest version its constraint allows; nothing moved")
}
for _, p := range res.Pruned {
fmt.Fprintf(stdout, "removed %s\n", p)
}
report(stdout, res)
return nil
}

func cmdOutdated(ctx context.Context, args []string, stdout, stderr io.Writer) error {
var c commonFlags
fs := newFlagSet("outdated", stderr, "[flags] [<resource>...]")
c.bind(fs)
asJSON := fs.Bool("json", false, "print the changes as a JSON array")
if err := parse(fs, args); err != nil {
return err
}
res, err := install.Outdated(ctx, c.options(stderr, false), fs.Args())
if err != nil {
return err
}
if *asJSON {
enc := json.NewEncoder(stdout)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if res.Changes == nil {
res.Changes = []install.Change{}
}
return enc.Encode(res.Changes)
}
for i := range res.Changes {
fmt.Fprintln(stdout, res.Changes[i].Describe())
}
if len(res.Changes) == 0 && !c.quiet {
fmt.Fprintln(stdout, "every package is at the highest version its constraint allows")
}
return nil
}

func cmdInstall(ctx context.Context, name string, args []string, stdout, stderr io.Writer, lockOnly bool) error {
var c commonFlags
fs := newFlagSet(name, stderr, "[flags]")
Expand Down
10 changes: 10 additions & 0 deletions docs/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ published into `lib/` only after every usage resolved and verified; a failing ru
and the lock as they were. A lock entry wins over the constraint in `.project.json` as long as it satisfies it;
`install` then verifies the installed copy's digest and fetches only what is missing or changed.

`update` is the one command that moves a lock entry. `syspi update [<resource>…]` re-resolves the
named usages of `.project.json` — every repository of the closure when none is named — to the
highest tagged version that satisfies their constraints, ignoring the lock; whatever moved is
installed, the lock rewritten, and the copies the moved entries left under `lib/` removed as
`remove` removes them. Every other command (`add`, `install`, `sync`, `lock`, `remove`) keeps a
lock entry that satisfies its constraint. `syspi outdated [-json] [<resource>…]` resolves the same
way and reports each entry that would move (`name old -> new`, plus entries the move would add or
drop), writing nothing. Archives, local directories and standard libraries have no versions to
choose from: their usage names what they are, and `update` leaves them.

## Install layout

```
Expand Down
154 changes: 131 additions & 23 deletions internal/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"os"
"path"
"path/filepath"
"sort"
"strings"

"github.com/Open-MBEE/SysPI/internal/digest"
Expand Down Expand Up @@ -41,6 +42,14 @@ type Options struct {
// Prune removes from lib/ the installed copies the previous lock placed
// there that the new lock no longer claims (see Remove).
Prune bool
// Update names, by repository key, the usages re-resolved to the highest
// version their constraint allows, ignoring the lock; UpdateAll does so
// for every repository of the closure (see Update and Outdated).
Update []string
UpdateAll bool
// DryRun resolves as LockOnly does but writes nothing: the Result
// carries the lock the run would have written.
DryRun bool
}

// Result reports what a run did.
Expand All @@ -54,6 +63,19 @@ type Result struct {
RemovedUsages []string
// Pruned lists the lib/ paths (relative to Root) a pruning run removed.
Pruned []string
// Changes lists, in resource order, every lock entry the run moved,
// added or dropped against the previous lock.
Changes []Change
}

// Change is one lock entry that differs between the previous and the new lock.
type Change struct {
Resource string `json:"resource"`
Name string `json:"name,omitempty"`
// From and To are the versions before and after; one is empty for an
// entry only one of the locks has.
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
}

type request struct {
Expand Down Expand Up @@ -112,6 +134,9 @@ func Run(ctx context.Context, opts Options) (*Result, error) {
return nil, err
}
opts.Root = root
if opts.DryRun {
opts.LockOnly = true
}
proj, err := project.ReadProject(root)
if err != nil {
return nil, err
Expand Down Expand Up @@ -144,6 +169,11 @@ func Run(ctx context.Context, opts Options) (*Result, error) {
return nil, err
}
}
r.res.Changes = changes(r.old, r.fresh)
if opts.DryRun {
r.res.Lock = r.fresh
return r.res, nil
}
// Everything resolved and verified: publish the staged packages, then
// the lock, restoring every replaced package if any step fails.
backups, err := r.publish()
Expand Down Expand Up @@ -349,13 +379,13 @@ func (r *resolver) archivePath(p *project.Project) string {
// installArchive stages a verified archive for lib/ unless an identical
// copy is already there, and queues its usages.
func (r *resolver) installArchive(e *lock.Entry, a *project.Archive, src string) error {
if r.opts.LockOnly {
return r.enqueueUsages(e, a.Project)
}
dst := r.archivePath(a.Project)
if err := r.claim(dst, e.Resource, a.SHA256); err != nil {
return err
}
if r.opts.LockOnly {
return r.enqueueUsages(e, a.Project)
}
if cur, err := digest.File(dst); (err == nil && cur == a.SHA256) || r.alreadyStaged(dst) {
r.res.Reused = append(r.res.Reused, e.Resource)
return r.enqueueUsages(e, a.Project)
Expand Down Expand Up @@ -454,10 +484,10 @@ func (r *resolver) reuseLocked(old *lock.Entry) (*lock.Entry, bool, error) {
dst := filepath.Join(r.libDir, stem+project.ArchiveExt)
cur, err := digest.File(dst)
if r.opts.LockOnly || (err == nil && cur == old.SHA256) {
if err := r.claim(dst, old.Resource, old.SHA256); err != nil {
return nil, false, err
}
if !r.opts.LockOnly {
if err := r.claim(dst, old.Resource, old.SHA256); err != nil {
return nil, false, err
}
r.res.Reused = append(r.res.Reused, old.Resource)
}
e := *old
Expand Down Expand Up @@ -572,6 +602,66 @@ func treePath(libDir string, p *project.Project) string {

// installedTreeMatches reports whether dir holds a tree with the locked
// digest, ignoring a .meta.json this tool generated.
// updating reports whether the lock is ignored for this repository: the
// run updates everything, or named this usage's repository.
func (r *resolver) updating(o *origin.Origin) bool {
if r.opts.UpdateAll {
return true
}
for _, k := range r.opts.Update {
if k == o.Key() {
return true
}
}
return false
}

// latest opens the repository and selects the highest tagged version that
// satisfies the request's constraint.
func (r *resolver) latest(o *origin.Origin, req request) (vcs.Source, vcs.Version, error) {
src, err := vcs.Open(o.VCS, o.Source)
if err != nil {
return nil, vcs.Version{}, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err)
}
vs, err := src.ListVersions(r.ctx)
if err != nil {
return nil, vcs.Version{}, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err)
}
sel, err := vcs.Select(vs, req.constraint)
if err != nil {
return nil, vcs.Version{}, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err)
}
return src, sel, nil
}

// changes diffs two locks by repository key (the old lock's resources are
// already keyed): every entry moved, added or dropped.
func changes(old, fresh *lock.Lock) []Change {
var out []Change
seen := map[string]bool{}
for i := range fresh.Packages {
e := &fresh.Packages[i]
k := repositoryKey(e.Resource)
seen[k] = true
c := Change{Resource: e.Resource, Name: e.Name, To: e.Version}
if o := old.Find(k); o != nil {
if o.Version == e.Version {
continue
}
c.From = o.Version
}
out = append(out, c)
}
for i := range old.Packages {
e := &old.Packages[i]
if !seen[repositoryKey(e.Resource)] {
out = append(out, Change{Resource: e.Resource, Name: e.Name, From: e.Version})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Resource < out[j].Resource })
return out
}

func installedTreeMatches(dir string, old *lock.Entry) bool {
if _, err := os.Stat(filepath.Join(dir, project.ProjectFile)); err != nil {
return false
Expand All @@ -587,6 +677,23 @@ func installedTreeMatches(dir string, old *lock.Entry) bool {
func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error) {
id := o.Identity()
old := r.lockedRepository(o, req.constraint)
var src vcs.Source
var sel vcs.Version
if old != nil && r.updating(o) {
if r.opts.Offline {
return nil, fmt.Errorf("%s: usage %q: offline; looking for a newer version of %s needs the repository", req.requester, req.resource, id)
}
var err error
if src, sel, err = r.latest(o, req); err != nil {
return nil, err
}
if sel.Version.String() != old.Version {
old = nil
} else {
// Already at the top: the locked tag, not another spelling of the same version.
sel = vcs.Version{Tag: old.Tag, Version: sel.Version}
}
}
if old != nil && !r.opts.LockOnly {
stem := (&project.Project{Name: old.Name, Publisher: old.Publisher, Version: old.Version}).InstallName()
dst := filepath.Join(r.libDir, stem)
Expand All @@ -601,28 +708,29 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error
}
}
if old != nil && r.opts.LockOnly {
if err := r.claim(treePath(r.libDir, &project.Project{Name: old.Name, Publisher: old.Publisher, Version: old.Version}), id, old.SHA256); err != nil {
return nil, err
}
e := *old
e.Resource, e.Source, e.Usage = id, o.Source, nil
return &e, r.requeueLocked(&e, old)
}
if r.opts.Offline {
return nil, fmt.Errorf("%s: usage %q: offline and %s is not installed under %s", req.requester, req.resource, id, project.LibDir)
}
src, err := vcs.Open(o.VCS, o.Source)
if err != nil {
return nil, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err)
}
var sel vcs.Version
if old != nil {
v, _ := version.Parse(old.Version)
sel = vcs.Version{Tag: old.Tag, Version: v}
} else {
vs, err := src.ListVersions(r.ctx)
if err != nil {
var err error
switch {
case src != nil:
// Opened and selected above: the lock is being moved.
case old != nil:
if src, err = vcs.Open(o.VCS, o.Source); err != nil {
return nil, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err)
}
if sel, err = vcs.Select(vs, req.constraint); err != nil {
return nil, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err)
v, _ := version.Parse(old.Version)
sel = vcs.Version{Tag: old.Tag, Version: v}
default:
if src, sel, err = r.latest(o, req); err != nil {
return nil, err
}
}
revision, err := src.Revision(r.ctx, sel)
Expand Down Expand Up @@ -688,6 +796,10 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error
return nil, fmt.Errorf("%s: usage %q: tag %s of %s exports tree SHA-256 %s, the lock recorded %s", req.requester, req.resource, sel.Tag, o.Source, sum, old.SHA256)
}
e := &lock.Entry{Resource: id, Kind: lock.KindVCS, Publisher: p.Publisher, Name: p.Name, Version: p.Version, VCS: o.VCS, Source: o.Source, Tag: sel.Tag, Revision: revision, SHA256: sum, MetaGenerated: !hasMeta}
dst := treePath(r.libDir, p)
if err := r.claim(dst, id, sum); err != nil {
return nil, err
}
if r.opts.LockOnly {
return e, r.enqueueUsages(e, p)
}
Expand All @@ -701,10 +813,6 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error
}
r.logf("%s %s: tag %s has no %s; generated one for the installed copy", p.Name, p.Version, sel.Tag, project.MetaFile)
}
dst := treePath(r.libDir, p)
if err := r.claim(dst, id, sum); err != nil {
return nil, err
}
if r.alreadyStaged(dst) {
r.res.Reused = append(r.res.Reused, id)
return e, r.enqueueUsages(e, p)
Expand Down
Loading
Loading