Skip to content

Commit b854747

Browse files
committed
feat: per-directory dev environments with declarative manifest
1 parent 14ae9fc commit b854747

155 files changed

Lines changed: 13386 additions & 41 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.idea
2+
bin
3+
dist
4+
*.out
5+
nem.ver

cmd/activate.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/vi-dev/nem/internal/output"
10+
"github.com/vi-dev/nem/internal/shell"
11+
)
12+
13+
func newActivateCmd() *cobra.Command {
14+
c := &cobra.Command{
15+
Use: "activate [bash|zsh|fish]",
16+
Short: "Install nem shell integration",
17+
GroupID: groupShell,
18+
Long: `Install the nem shell integration into your shell rc file.
19+
20+
Without arguments, the shell is detected from $SHELL.
21+
22+
The integration enables:
23+
- Automatic PATH updates when changing directories and after running
24+
'nem use', 'nem unuse', 'nem refresh', or 'nem set-env/unset-env'.
25+
- Tab-completion for the 'nem' command (dynamic for package and
26+
catalog names where applicable).
27+
28+
Run 'nem deactivate' to remove the integration.
29+
30+
When stdout is not a terminal (e.g. 'eval "$(nem activate zsh)"') the
31+
script is printed instead of being installed, so legacy setups continue
32+
to work. Pass --print to force this behaviour.`,
33+
Args: cobra.MaximumNArgs(1),
34+
RunE: func(cmd *cobra.Command, args []string) error {
35+
printOnly, _ := cmd.Flags().GetBool("print")
36+
name := shell.DetectShell()
37+
if len(args) > 0 {
38+
name = strings.ToLower(args[0])
39+
}
40+
body, err := shell.Script(name)
41+
if err != nil {
42+
return err
43+
}
44+
if printOnly || !shell.StdoutIsTerminal() {
45+
fmt.Print(body)
46+
return nil
47+
}
48+
path, err := shell.RCPath(name)
49+
if err != nil {
50+
return err
51+
}
52+
added, err := shell.AppendBlock(path, body)
53+
if err != nil {
54+
return err
55+
}
56+
if !added {
57+
output.Infof("nem integration already present in %q", path)
58+
output.Infof("to refresh (e.g. pick up new shell features), run: nem deactivate && nem activate")
59+
return nil
60+
}
61+
output.Infof("added nem integration to %q", path)
62+
output.Infof("reload your shell or run 'source %s' to activate", path)
63+
return nil
64+
},
65+
}
66+
c.Flags().Bool("print", false, "print the integration script to stdout instead of installing it")
67+
return c
68+
}

cmd/author/cmd.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package author
2+
3+
import "github.com/spf13/cobra"
4+
5+
var AuthorCmd = &cobra.Command{
6+
Use: "author",
7+
Short: "Catalog authoring tools",
8+
}
9+
10+
func init() {
11+
AuthorCmd.AddCommand(lintCmd)
12+
}

cmd/author/lint.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package author
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/vi-dev/nem/internal/app"
10+
"github.com/vi-dev/nem/internal/config"
11+
"github.com/vi-dev/nem/internal/linter"
12+
"github.com/vi-dev/nem/internal/pkg"
13+
)
14+
15+
var lintCmd = &cobra.Command{
16+
Use: "lint [path]",
17+
Short: "Validate pkg.yaml manifests before publishing",
18+
Long: `Validate one or more pkg.yaml package manifests for structural and
19+
semantic errors. The path may be:
20+
- a pkg.yaml file
21+
- a package directory containing pkg.yaml
22+
- a catalog directory containing <name>/pkg.yaml entries (lints all)
23+
- omitted, in which case the current directory is used
24+
25+
With --install, every package that passes the offline checks is then
26+
installed and tested against an isolated NEM_HOME. Install/test
27+
failures are reported as findings just like offline ones.`,
28+
Args: cobra.MaximumNArgs(1),
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
installFlag, _ := cmd.Flags().GetBool("install")
31+
path := "."
32+
if len(args) == 1 {
33+
path = args[0]
34+
}
35+
res, err := runLint(cmd.Context(), path, installFlag)
36+
if err != nil {
37+
return err
38+
}
39+
w := cmd.OutOrStdout()
40+
for _, f := range res.Findings {
41+
if f.Field != "" {
42+
fmt.Fprintf(w, "%s: %s: %s\n", f.File, f.Field, f.Message)
43+
} else {
44+
fmt.Fprintf(w, "%s: %s\n", f.File, f.Message)
45+
}
46+
}
47+
if len(res.Findings) > 0 {
48+
return fmt.Errorf("%d finding(s)", len(res.Findings))
49+
}
50+
fmt.Fprintln(w, "ok")
51+
return nil
52+
},
53+
}
54+
55+
func init() {
56+
lintCmd.Flags().Bool("install", false, "also install and test each package that passes offline checks")
57+
}
58+
59+
func runLint(ctx context.Context, path string, install bool) (linter.Result, error) {
60+
if !install {
61+
return linter.Lint(path)
62+
}
63+
userCfg, err := config.Load()
64+
if err != nil {
65+
return linter.Result{}, fmt.Errorf("load user config: %w", err)
66+
}
67+
return linter.LintWithInstall(ctx, path, userCfg.Auth, installProbe)
68+
}
69+
70+
func installProbe(ctx context.Context, name string) (string, error) {
71+
a, err := app.Open()
72+
if err != nil {
73+
return "", fmt.Errorf("app.Open: %w", err)
74+
}
75+
latest, _, err := a.Catalogs().Latest(ctx, name)
76+
if err != nil {
77+
return "", err
78+
}
79+
if _, err := a.Pkg().Install(ctx, pkg.Refs{{Name: name, Version: latest}}); err != nil {
80+
return latest, err
81+
}
82+
return latest, nil
83+
}

cmd/author/lint_auth_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package author
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"os"
10+
"path/filepath"
11+
"sync/atomic"
12+
"testing"
13+
)
14+
15+
// TestLint_InstallPropagatesUserAuth verifies that auth credentials configured
16+
// in the user's $NEM_HOME/config.yaml reach the fetcher during
17+
// `nem author lint --install`, which runs the install against an isolated
18+
// temp NEM_HOME.
19+
func TestLint_InstallPropagatesUserAuth(t *testing.T) {
20+
var sawAuth atomic.Value
21+
sawAuth.Store("")
22+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+
sawAuth.Store(r.Header.Get("Authorization"))
24+
w.WriteHeader(http.StatusUnauthorized)
25+
_, _ = io.WriteString(w, "unauthorized")
26+
}))
27+
t.Cleanup(srv.Close)
28+
29+
u, err := url.Parse(srv.URL)
30+
if err != nil {
31+
t.Fatal(err)
32+
}
33+
34+
nemHome := t.TempDir()
35+
t.Setenv("NEM_HOME", nemHome)
36+
cfgYAML := fmt.Sprintf(
37+
"catalogs: []\nauth:\n http:\n - host: %s\n bearer: lint-secret\n",
38+
u.Host,
39+
)
40+
if err := os.WriteFile(filepath.Join(nemHome, "config.yaml"), []byte(cfgYAML), 0o644); err != nil {
41+
t.Fatal(err)
42+
}
43+
44+
pkgDir := filepath.Join(t.TempDir(), "lint-auth-pkg")
45+
if err := os.MkdirAll(pkgDir, 0o755); err != nil {
46+
t.Fatal(err)
47+
}
48+
pkgYAML := fmt.Sprintf(
49+
"name: lint-auth-pkg\n"+
50+
"archive:\n"+
51+
" fetcher:\n"+
52+
" http:\n"+
53+
" url: %s/pkg-{{.Version}}.tar.gz\n"+
54+
" verify:\n"+
55+
" sha256: \"0000000000000000000000000000000000000000000000000000000000000000\"\n"+
56+
"versions:\n"+
57+
" - version: 1.0.0\n",
58+
srv.URL)
59+
if err := os.WriteFile(filepath.Join(pkgDir, "pkg.yaml"), []byte(pkgYAML), 0o644); err != nil {
60+
t.Fatal(err)
61+
}
62+
63+
root, _ := newRootWithAuthor(t)
64+
root.SetArgs([]string{"author", "lint", "--install", pkgDir})
65+
_ = root.Execute()
66+
67+
if got, _ := sawAuth.Load().(string); got != "Bearer lint-secret" {
68+
t.Errorf("test server saw Authorization = %q; want %q", got, "Bearer lint-secret")
69+
}
70+
}

cmd/author/lint_install_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package author
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"runtime"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func ensureSandboxAvailable(t *testing.T) {
13+
t.Helper()
14+
switch runtime.GOOS {
15+
case "darwin":
16+
if _, err := os.Stat("/usr/bin/sandbox-exec"); err != nil {
17+
t.Skipf("sandbox-exec unavailable: %v", err)
18+
}
19+
case "linux":
20+
if _, err := exec.LookPath("bwrap"); err != nil {
21+
t.Skip("bwrap not installed")
22+
}
23+
default:
24+
t.Skipf("sandbox not supported on %s", runtime.GOOS)
25+
}
26+
}
27+
28+
// TestLint_InstallSurfacesFetchFailureAsFinding is a sandbox-gated
29+
// end-to-end test that confirms --install routes installer errors through
30+
// to a Finding (rather than crashing). It uses the linter's `valid`
31+
// fixture, whose github_release fetcher points at a nonexistent repo so
32+
// the fetch deterministically fails.
33+
func TestLint_InstallSurfacesFetchFailureAsFinding(t *testing.T) {
34+
if testing.Short() {
35+
t.Skip("end-to-end --install test skipped under -short")
36+
}
37+
ensureSandboxAvailable(t)
38+
valid := filepath.Join(repoRoot(t), "internal", "linter", "testdata", "valid")
39+
root, stdout := newRootWithAuthor(t)
40+
root.SetArgs([]string{"author", "lint", "--install", valid})
41+
err := root.Execute()
42+
if err == nil {
43+
t.Fatalf("expected install failure to surface as a finding; stdout=%q", stdout.String())
44+
}
45+
if !strings.Contains(stdout.String(), ": install:") {
46+
t.Errorf("expected install: finding line in stdout; got %q", stdout.String())
47+
}
48+
}

cmd/author/lint_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package author
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/spf13/cobra"
11+
)
12+
13+
func newRootWithAuthor(t *testing.T) (*cobra.Command, *bytes.Buffer) {
14+
t.Helper()
15+
root := &cobra.Command{Use: "nem", SilenceUsage: true}
16+
root.AddCommand(AuthorCmd)
17+
t.Cleanup(func() { lintCmd.Flags().Set("install", "false") })
18+
var stdout, stderr bytes.Buffer
19+
root.SetOut(&stdout)
20+
root.SetErr(&stderr)
21+
return root, &stdout
22+
}
23+
24+
func TestLint_CleanFixtureExitsZero(t *testing.T) {
25+
valid := filepath.Join(repoRoot(t), "internal", "linter", "testdata", "valid")
26+
root, _ := newRootWithAuthor(t)
27+
root.SetArgs([]string{"author", "lint", valid})
28+
if err := root.Execute(); err != nil {
29+
t.Fatalf("lint clean fixture: unexpected error %v", err)
30+
}
31+
}
32+
33+
func TestLint_FixtureWithFindingExitsNonZero(t *testing.T) {
34+
missingName := filepath.Join(repoRoot(t), "internal", "linter", "testdata", "missing-name")
35+
root, stdout := newRootWithAuthor(t)
36+
root.SetArgs([]string{"author", "lint", missingName})
37+
err := root.Execute()
38+
if err == nil {
39+
t.Fatalf("expected non-nil error for fixture with findings; stdout=%q", stdout.String())
40+
}
41+
if !strings.Contains(stdout.String(), "name: must not be empty") {
42+
t.Errorf("expected finding in stdout; got %q", stdout.String())
43+
}
44+
}
45+
46+
func TestLint_InstallSkipsPackagesWithOfflineFindings(t *testing.T) {
47+
// Build a tiny repo dir with one package that has a malformed manifest.
48+
// With --install set, the failing-offline package contributes a finding
49+
// and the install loop must NOT attempt it (the broken manifest has no
50+
// archive/versions, so any install attempt would surface a SECOND
51+
// finding from the install path).
52+
repo := t.TempDir()
53+
bad := filepath.Join(repo, "broken")
54+
if err := os.MkdirAll(bad, 0o755); err != nil {
55+
t.Fatal(err)
56+
}
57+
if err := os.WriteFile(filepath.Join(bad, "pkg.yaml"),
58+
[]byte("name: broken\n"), 0o644); err != nil {
59+
t.Fatal(err)
60+
}
61+
root, stdout := newRootWithAuthor(t)
62+
root.SetArgs([]string{"author", "lint", "--install", repo})
63+
err := root.Execute()
64+
if err == nil {
65+
t.Fatalf("expected non-nil error; stdout=%q", stdout.String())
66+
}
67+
// Exactly one finding line should appear in captured stdout: the offline
68+
// "archive must be set" (B6) finding. If --install were attempting the
69+
// broken package, additional findings would show up.
70+
findingLines := 0
71+
for _, line := range strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") {
72+
if line != "" {
73+
findingLines++
74+
}
75+
}
76+
if findingLines != 1 {
77+
t.Errorf("expected exactly 1 finding line (offline only); got %d: %q", findingLines, stdout.String())
78+
}
79+
}
80+
81+
// repoRoot walks up from the test working directory until it finds go.mod,
82+
// so the test can reference internal/linter/testdata regardless of which
83+
// package directory `go test` runs in.
84+
func repoRoot(t *testing.T) string {
85+
t.Helper()
86+
dir, err := os.Getwd()
87+
if err != nil {
88+
t.Fatal(err)
89+
}
90+
for {
91+
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
92+
return dir
93+
}
94+
parent := filepath.Dir(dir)
95+
if parent == dir {
96+
t.Fatal("could not locate go.mod from cwd")
97+
}
98+
dir = parent
99+
}
100+
}

0 commit comments

Comments
 (0)