From 8bfd74d12c371bad9d11be30423cdfa6d6a4cd9d Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 12:21:06 -0400 Subject: [PATCH 1/8] fix(ci): green main on macOS, Windows, v1.5 Linux, lint and the coverage gate - config explain monorepo test: compare the resolved cwd (macOS /var is a symlink to /private/var) - reconcile entitlement test: publish valid compose so the v1.5 image-pin read parses it - proxy restore: close the anchoring os.Root before removing the directory (Windows refuses to remove a directory with an open handle) - build: skip the .env 0600 fix on Windows, where permission bits do not exist and the plan can never match the apply - compose: check the dry-run Fprintf result (errcheck) - cmdregistry: tests for FlagInsertionIndex and NoopBoolValue to clear the 96 percent floor - coverage floors for envrestore and frontcheck, which strict mode requires --- .github/coverage-floors.txt | 2 + cmd/commands/config_explain_monorepo_test.go | 10 +++- internal/build/orchestrator_build_config.go | 7 +++ internal/build/proxy_restore.go | 16 +++++-- internal/cmdregistry/flag_argv_test.go | 50 ++++++++++++++++++++ internal/compose/dry_run_env.go | 2 +- internal/reconcile/state_test.go | 8 +++- 7 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 internal/cmdregistry/flag_argv_test.go diff --git a/.github/coverage-floors.txt b/.github/coverage-floors.txt index 8a7fe670b..11a0bafc9 100644 --- a/.github/coverage-floors.txt +++ b/.github/coverage-floors.txt @@ -69,10 +69,12 @@ internal/doctor/report 85 # measured 87.8 2026-10-11 internal/domain 60 # measured 62.1 2026-10-05 internal/embedded 78 # measured 80.8 2026-10-05 internal/env 19 # measured 21.7 2026-10-05 +internal/envrestore 61 # measured 63.4 2026-10-29 internal/errs 93 # measured 95.8 2026-10-05 internal/eval 77 # measured 79.1 2026-10-05 internal/exporter 58 # measured 60.2 2026-10-12 (P7-ADOPT-17; snapshot, pg_dump and auth reads need a live Postgres, covered by scripts/e2e/export.sh) internal/featureflags 69 # measured 71.7 2026-10-05 +internal/frontcheck 0 # measured 0.0 2026-10-29 (no test files yet) internal/generate 65 # measured 67.5 2026-10-05 internal/hasura 87 # measured 89.1 2026-10-05 internal/health 26 # measured 28.1 2026-10-05 diff --git a/cmd/commands/config_explain_monorepo_test.go b/cmd/commands/config_explain_monorepo_test.go index f64effc85..8d1fe0cb5 100644 --- a/cmd/commands/config_explain_monorepo_test.go +++ b/cmd/commands/config_explain_monorepo_test.go @@ -71,8 +71,14 @@ func TestConfigExplainMonorepoExecution(t *testing.T) { if tc.redirect { wantDir = backend } - if cwd, err := os.Getwd(); err != nil || cwd != wantDir { - t.Errorf("cwd = %q (%v), want %q", cwd, err, wantDir) + // os.Getwd reports the resolved path (macOS temp dirs live behind a + // /var -> /private/var symlink), so compare resolved paths. + wantResolved, err := filepath.EvalSymlinks(wantDir) + if err != nil { + t.Fatal(err) + } + if cwd, err := os.Getwd(); err != nil || cwd != wantResolved { + t.Errorf("cwd = %q (%v), want %q", cwd, err, wantResolved) } }) } diff --git a/internal/build/orchestrator_build_config.go b/internal/build/orchestrator_build_config.go index 25fc8fdf4..fa236f336 100644 --- a/internal/build/orchestrator_build_config.go +++ b/internal/build/orchestrator_build_config.go @@ -17,6 +17,7 @@ import ( "log/slog" "os" "path/filepath" + "runtime" "strings" "time" @@ -156,7 +157,13 @@ func (st *buildState) loadValidateConfig() (*BuildResult, error) { // ensureEnvFilePermissions fixes an existing env file to 0600 when it is more // permissive, through the sink (setup.EnsureEnvFilePermissions semantics: a // missing file is a no-op). Plan mode records the mode change, not applies it. +// Windows has no owner-only permission bits (os.Stat always reports 0666 or +// 0444 and chmod only toggles the read-only flag), so there is nothing to fix +// and the plan never records a mode the apply could then have to match. func ensureEnvFilePermissions(sink Sink, path string) error { + if runtime.GOOS == "windows" { + return nil + } info, err := sink.Stat(path) if os.IsNotExist(err) { return nil diff --git a/internal/build/proxy_restore.go b/internal/build/proxy_restore.go index 0a93ebb80..3e817a3ba 100644 --- a/internal/build/proxy_restore.go +++ b/internal/build/proxy_restore.go @@ -141,6 +141,17 @@ func removeTree(sink Sink, dir string, before fs.FileInfo) error { if !before.IsDir() { return fmt.Errorf("cleanup directory %s was replaced", dir) } + if err := emptyTree(sink, dir, before); err != nil { + return err + } + // The anchoring handle is closed by now: Windows refuses to remove a + // directory while any handle on it is open. + return sink.Remove(dir) +} + +// emptyTree unlinks everything under dir through a handle anchored on it and +// releases the handle before returning. +func emptyTree(sink Sink, dir string, before fs.FileInfo) error { root, err := os.OpenRoot(dir) if err != nil { return err @@ -153,10 +164,7 @@ func removeTree(sink Sink, dir string, before fs.FileInfo) error { if !os.SameFile(before, after) { return fmt.Errorf("cleanup directory %s was replaced", dir) } - if err := removeTreeIn(sink, root, dir, "."); err != nil { - return err - } - return sink.Remove(dir) + return removeTreeIn(sink, root, dir, ".") } // removeTreeIn checks each listing against the anchored directory identity. diff --git a/internal/cmdregistry/flag_argv_test.go b/internal/cmdregistry/flag_argv_test.go new file mode 100644 index 000000000..d75010a1c --- /dev/null +++ b/internal/cmdregistry/flag_argv_test.go @@ -0,0 +1,50 @@ +package cmdregistry + +import ( + "testing" + + "github.com/spf13/pflag" +) + +func TestFlagInsertionIndex(t *testing.T) { + local := pflag.NewFlagSet("local", pflag.ContinueOnError) + local.String("name", "", "takes a value") + inherited := pflag.NewFlagSet("inherited", pflag.ContinueOnError) + inherited.String("env", "", "takes a value") + inherited.Bool("verbose", false, "bool") + inherited.Lookup("verbose").NoOptDefVal = "true" + + for _, tc := range []struct { + desc string + args []string + wantIndex int + wantFound bool + }{ + {"no args", nil, 0, false}, + {"positional only", []string{"a", "b"}, 2, false}, + {"separator stops the scan", []string{"a", "--", "--json"}, 1, false}, + {"flag already forwarded", []string{"a", "--json"}, 1, true}, + {"forwarded inline", []string{"--json=false"}, 0, true}, + {"local value flag skips its value", []string{"--name", "--json"}, 2, false}, + {"inherited value flag skips its value", []string{"--env", "--json", "x"}, 3, false}, + {"inline value is not skipped", []string{"--name=x", "--json"}, 1, true}, + {"no-value flag is not skipped", []string{"--verbose", "--json"}, 1, true}, + {"unknown flag is not skipped", []string{"--other", "--json"}, 1, true}, + {"single dash is positional", []string{"-x", "--"}, 1, false}, + } { + got, found := FlagInsertionIndex(tc.args, "json", local, inherited) + if got != tc.wantIndex || found != tc.wantFound { + t.Errorf("%s: FlagInsertionIndex(%q) = (%d, %t), want (%d, %t)", tc.desc, tc.args, got, found, tc.wantIndex, tc.wantFound) + } + } +} + +func TestNoopBoolValueIgnoresWrites(t *testing.T) { + var v NoopBoolValue + if err := v.Set("true"); err != nil { + t.Fatalf("Set returned %v, want nil", err) + } + if v.String() != "false" || v.Type() != "bool" { + t.Fatalf("NoopBoolValue = (%q, %q), want (false, bool)", v.String(), v.Type()) + } +} diff --git a/internal/compose/dry_run_env.go b/internal/compose/dry_run_env.go index 1635bdcd3..778b5ffd4 100644 --- a/internal/compose/dry_run_env.go +++ b/internal/compose/dry_run_env.go @@ -32,6 +32,6 @@ func PrintCustomServiceEnvSummary(w io.Writer, workdir string) { } for _, cs := range cfg.CustomServices { names := CustomServiceEnvVarNames(cs) - fmt.Fprintf(w, " [dry-run] Custom service %s will receive: %s\n", cs.Name, strings.Join(names, ", ")) + _, _ = fmt.Fprintf(w, " [dry-run] Custom service %s will receive: %s\n", cs.Name, strings.Join(names, ", ")) } } diff --git a/internal/reconcile/state_test.go b/internal/reconcile/state_test.go index e7c9e3c9f..3aea9e7ca 100644 --- a/internal/reconcile/state_test.go +++ b/internal/reconcile/state_test.go @@ -544,6 +544,10 @@ func TestApplyRecordWriteFailureIsPublished(t *testing.T) { // generated file was written, with a completed result. The apply fails but the // files stay, so the error must match ErrPublished; a failure with no result // (nothing committed) or with a caller Rollback that restores the files must not. +// publishedCompose is the compose file the faked build publishes. It must be +// valid compose: under NSELF_V15=1 the next apply reads it for the image pins. +const publishedCompose = "services: {}\n" + func TestApplyEntitlementRefreshFailureIsPublished(t *testing.T) { f := loadFixture(t, "dev-minimal") _ = os.RemoveAll(filepath.Join(f.project, ".nself", "state")) @@ -551,7 +555,7 @@ func TestApplyEntitlementRefreshFailureIsPublished(t *testing.T) { t.Cleanup(func() { buildBuild = oldBuild }) refresh := &nbuild.EntitlementRefreshError{Err: errs.New("E125", "plugin entitlement unavailable")} buildBuild = func(projectDir string, opts nbuild.BuildOptions) (*nbuild.BuildResult, error) { - if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("published"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte(publishedCompose), 0o644); err != nil { return nil, err } return &nbuild.BuildResult{}, refresh @@ -565,7 +569,7 @@ func TestApplyEntitlementRefreshFailureIsPublished(t *testing.T) { t.Fatalf("err = %v, want the E125 cause kept on the chain", err) } - if data, readErr := os.ReadFile(filepath.Join(f.project, "docker-compose.yml")); readErr != nil || string(data) != "published" { + if data, readErr := os.ReadFile(filepath.Join(f.project, "docker-compose.yml")); readErr != nil || string(data) != publishedCompose { t.Fatalf("published artifacts must remain after E125: %q, %v", data, readErr) } if detail := errs.Describe(err); detail == nil || detail.Code != "E125" || errs.ExitCodeFor(err) != 3 { From 1f98b56e958811718575478883c2d480bd0e442e Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 13:44:39 -0400 Subject: [PATCH 2/8] fix(ci): hold the package-size ratchet, finish the Windows cleanup handles, compare cwd by identity - move the unwritable-directory scan out of internal/build into internal/dirwritable (build shrinks below its ceiling) - release the publisher's cached cleanup handle before removing that directory (Windows) - config explain monorepo test: compare the working directory with os.SameFile (macOS /var symlink, Windows 8.3 names) - coverage floor for internal/dirwritable --- .github/coverage-floors.txt | 1 + cmd/commands/config_explain_monorepo_test.go | 15 +++-- internal/build/expect_publish.go | 8 +++ internal/build/orchestrator_build_config.go | 4 +- internal/build/proxy_restore.go | 46 +++------------- internal/build/proxy_restore_windows.go | 7 --- internal/dirwritable/dirwritable.go | 35 ++++++++++++ internal/dirwritable/dirwritable_test.go | 55 +++++++++++++++++++ .../writable_unix.go} | 4 +- internal/dirwritable/writable_windows.go | 7 +++ 10 files changed, 124 insertions(+), 58 deletions(-) delete mode 100644 internal/build/proxy_restore_windows.go create mode 100644 internal/dirwritable/dirwritable.go create mode 100644 internal/dirwritable/dirwritable_test.go rename internal/{build/proxy_restore_unix.go => dirwritable/writable_unix.go} (78%) create mode 100644 internal/dirwritable/writable_windows.go diff --git a/.github/coverage-floors.txt b/.github/coverage-floors.txt index 11a0bafc9..b309bb94f 100644 --- a/.github/coverage-floors.txt +++ b/.github/coverage-floors.txt @@ -62,6 +62,7 @@ internal/deploy/bluegreen 44 # measured 46.3 2026-10-05 internal/deploy/pintest 57 # measured 59.5 2026-10-13 (P7-HYG-49) internal/deploy/progress 86 # measured 88.8 2026-10-12 internal/deprecation 75 # measured 77.3 2026-10-05 +internal/dirwritable 91 # measured 93.8 2026-10-29 internal/docker 52 # measured 54.7 2026-10-05 internal/docker/dockertest 85 # measured 89.5 (darwin, local) 2026-10-11; new package (D-0286) internal/doctor 47 # measured 49.6 2026-10-05 diff --git a/cmd/commands/config_explain_monorepo_test.go b/cmd/commands/config_explain_monorepo_test.go index 8d1fe0cb5..70f0d2868 100644 --- a/cmd/commands/config_explain_monorepo_test.go +++ b/cmd/commands/config_explain_monorepo_test.go @@ -71,14 +71,13 @@ func TestConfigExplainMonorepoExecution(t *testing.T) { if tc.redirect { wantDir = backend } - // os.Getwd reports the resolved path (macOS temp dirs live behind a - // /var -> /private/var symlink), so compare resolved paths. - wantResolved, err := filepath.EvalSymlinks(wantDir) - if err != nil { - t.Fatal(err) - } - if cwd, err := os.Getwd(); err != nil || cwd != wantResolved { - t.Errorf("cwd = %q (%v), want %q", cwd, err, wantResolved) + // Paths differ in form (macOS /var vs /private/var, Windows 8.3 short + // names), so compare the directories themselves. + cwdInfo, cwdErr := os.Stat(".") + wantInfo, wantErr := os.Stat(wantDir) + if cwdErr != nil || wantErr != nil || !os.SameFile(cwdInfo, wantInfo) { + cwd, _ := os.Getwd() + t.Errorf("cwd = %q (%v), want the directory %q (%v)", cwd, cwdErr, wantDir, wantErr) } }) } diff --git a/internal/build/expect_publish.go b/internal/build/expect_publish.go index 3737b94d4..6a1fc58a8 100644 --- a/internal/build/expect_publish.go +++ b/internal/build/expect_publish.go @@ -131,6 +131,13 @@ func (p *artifactPublisher) target(path string) (*os.Root, string, error) { return root, filepath.FromSlash(rel), nil } +func (p *artifactPublisher) releaseCleanupRoot(path string) { + if abs, err := filepath.Abs(p.sink.abs(path)); err == nil && p.roots[abs] != nil { + _ = p.roots[abs].Close() + delete(p.roots, abs) + } +} + func (p *artifactPublisher) publish() error { for _, k := range sortedArtifactKeys(p.sink.dirs) { if err := p.mkdir(p.sink.abs(k), p.sink.dirs[k]); err != nil { @@ -173,6 +180,7 @@ func (p *artifactPublisher) publish() error { if err != nil { return err } + p.releaseCleanupRoot(k) // Windows cannot remove a directory with an open handle if err := root.Remove(rel); err != nil { return err } diff --git a/internal/build/orchestrator_build_config.go b/internal/build/orchestrator_build_config.go index fa236f336..29861b800 100644 --- a/internal/build/orchestrator_build_config.go +++ b/internal/build/orchestrator_build_config.go @@ -157,9 +157,7 @@ func (st *buildState) loadValidateConfig() (*BuildResult, error) { // ensureEnvFilePermissions fixes an existing env file to 0600 when it is more // permissive, through the sink (setup.EnsureEnvFilePermissions semantics: a // missing file is a no-op). Plan mode records the mode change, not applies it. -// Windows has no owner-only permission bits (os.Stat always reports 0666 or -// 0444 and chmod only toggles the read-only flag), so there is nothing to fix -// and the plan never records a mode the apply could then have to match. +// Windows has no owner-only permission bits, so there is nothing to fix there. func ensureEnvFilePermissions(sink Sink, path string) error { if runtime.GOOS == "windows" { return nil diff --git a/internal/build/proxy_restore.go b/internal/build/proxy_restore.go index 3e817a3ba..d83565542 100644 --- a/internal/build/proxy_restore.go +++ b/internal/build/proxy_restore.go @@ -13,6 +13,8 @@ import ( "os" "path/filepath" "sort" + + "github.com/nself-org/cli/internal/dirwritable" ) // The traefik renderer marks its output with this file and prefix. @@ -61,7 +63,7 @@ func cleanupOutputDir(sink Sink, workdir, dir string) error { return proxyKeep(dir, why) } // Plan and apply must agree on removals even for unwritable output. - if why := unwritableIn(filepath.Dir(dir), dir); why != "" { + if why := dirwritable.FirstUnwritable(filepath.Dir(dir), dir); why != "" { return proxyKeep(dir, "it is owned by root (the traefik-config container wrote it): "+why+" is not writable; run `sudo rm -rf "+dir+"` or chown it to your user, then build again") } err = removeTree(sink, dir, info) @@ -75,31 +77,6 @@ func cleanupOutputDir(sink Sink, workdir, dir string) error { return nil } -// unwritableIn finds the first directory that prevents unlinking, parent first. -func unwritableIn(parent, dir string) string { - if !dirWritable(parent) { - return parent - } - var bad string - _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { - if bad != "" { - return nil - } - if err != nil { // an unreadable directory cannot be emptied either - bad = p - return nil - } - if !d.IsDir() { - return nil - } - if !dirWritable(p) { - bad = p - } - return nil - }) - return bad -} - // proxyKeep warns and continues without deleting unproven output. func proxyKeep(dir, why string) error { slog.Warn("leaving "+dir+" in place: "+why, @@ -141,17 +118,6 @@ func removeTree(sink Sink, dir string, before fs.FileInfo) error { if !before.IsDir() { return fmt.Errorf("cleanup directory %s was replaced", dir) } - if err := emptyTree(sink, dir, before); err != nil { - return err - } - // The anchoring handle is closed by now: Windows refuses to remove a - // directory while any handle on it is open. - return sink.Remove(dir) -} - -// emptyTree unlinks everything under dir through a handle anchored on it and -// releases the handle before returning. -func emptyTree(sink Sink, dir string, before fs.FileInfo) error { root, err := os.OpenRoot(dir) if err != nil { return err @@ -164,7 +130,11 @@ func emptyTree(sink Sink, dir string, before fs.FileInfo) error { if !os.SameFile(before, after) { return fmt.Errorf("cleanup directory %s was replaced", dir) } - return removeTreeIn(sink, root, dir, ".") + if err := removeTreeIn(sink, root, dir, "."); err != nil { + return err + } + _ = root.Close() // Windows cannot remove a directory while a handle on it is open + return sink.Remove(dir) } // removeTreeIn checks each listing against the anchored directory identity. diff --git a/internal/build/proxy_restore_windows.go b/internal/build/proxy_restore_windows.go deleted file mode 100644 index b179549d5..000000000 --- a/internal/build/proxy_restore_windows.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build windows - -package build - -// dirWritable has no access(2) on Windows; a failed removal is reported by the -// remove itself, which cleanupOutputDir treats the same way. -func dirWritable(string) bool { return true } diff --git a/internal/dirwritable/dirwritable.go b/internal/dirwritable/dirwritable.go new file mode 100644 index 000000000..c88b97740 --- /dev/null +++ b/internal/dirwritable/dirwritable.go @@ -0,0 +1,35 @@ +// Package dirwritable tells, before anything is planned or deleted, whether the +// current user can unlink inside a directory tree (the traefik-config one-shot +// runs as root, so Docker creates its bind directories root-owned on Linux). +// It is read-only (access(2)) and identical in plan and apply mode. +package dirwritable + +import ( + "io/fs" + "path/filepath" +) + +// FirstUnwritable finds the first directory that prevents unlinking, parent first. +func FirstUnwritable(parent, dir string) string { + if !writable(parent) { + return parent + } + var bad string + _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if bad != "" { + return nil + } + if err != nil { // an unreadable directory cannot be emptied either + bad = p + return nil + } + if !d.IsDir() { + return nil + } + if !writable(p) { + bad = p + } + return nil + }) + return bad +} diff --git a/internal/dirwritable/dirwritable_test.go b/internal/dirwritable/dirwritable_test.go new file mode 100644 index 000000000..f080039eb --- /dev/null +++ b/internal/dirwritable/dirwritable_test.go @@ -0,0 +1,55 @@ +package dirwritable + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestFirstUnwritableCleanTree(t *testing.T) { + dir := filepath.Join(t.TempDir(), "out") + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "sub", "f"), nil, 0o644); err != nil { + t.Fatal(err) + } + if got := FirstUnwritable(filepath.Dir(dir), dir); got != "" { + t.Fatalf("FirstUnwritable = %q, want none", got) + } +} + +func TestFirstUnwritableReportsLockedDirectories(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("directory permissions are not enforced here") + } + root := t.TempDir() + dir := filepath.Join(root, "out") + locked := filepath.Join(dir, "locked") + if err := os.MkdirAll(locked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(locked, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + if got := FirstUnwritable(root, dir); got != locked { + t.Fatalf("FirstUnwritable = %q, want %q", got, locked) + } + if err := os.Chmod(root, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + if got := FirstUnwritable(root, dir); got != root { + t.Fatalf("FirstUnwritable = %q, want the parent %q", got, root) + } +} + +func TestFirstUnwritableUnreadableTree(t *testing.T) { + root := t.TempDir() + missing := filepath.Join(root, "missing") + if got := FirstUnwritable(root, missing); got != missing { + t.Fatalf("FirstUnwritable = %q, want %q for an unreadable tree", got, missing) + } +} diff --git a/internal/build/proxy_restore_unix.go b/internal/dirwritable/writable_unix.go similarity index 78% rename from internal/build/proxy_restore_unix.go rename to internal/dirwritable/writable_unix.go index 343a88599..13ab6efac 100644 --- a/internal/build/proxy_restore_unix.go +++ b/internal/dirwritable/writable_unix.go @@ -1,6 +1,6 @@ //go:build !windows -package build +package dirwritable // Purpose: tell, before anything is planned or deleted, whether this user can // unlink inside a directory (the traefik-config one-shot runs as root, so Docker @@ -10,4 +10,4 @@ package build import "syscall" -func dirWritable(dir string) bool { return syscall.Access(dir, 0x2|0x1) == nil } // W_OK|X_OK +func writable(dir string) bool { return syscall.Access(dir, 0x2|0x1) == nil } // W_OK|X_OK diff --git a/internal/dirwritable/writable_windows.go b/internal/dirwritable/writable_windows.go new file mode 100644 index 000000000..353ae077a --- /dev/null +++ b/internal/dirwritable/writable_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package dirwritable + +// writable has no access(2) on Windows; a failed removal is reported by the +// remove itself, which the build cleanup treats the same way. +func writable(string) bool { return true } From 670c05533ca35ef9ab2421dc87f049c243df4d17 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 18:13:25 -0400 Subject: [PATCH 3/8] fix(ci): drop the unused duplicate reconcile rollback, add floors for pathmap, hostpin and pluginperm rollbackReconcile (#925) was never called: undoFreshMembers from #945 does the same job and the Install tests cover it. --- .github/coverage-floors.txt | 3 ++ .../bundle/installer_reconcile_rollback.go | 54 ------------------- 2 files changed, 3 insertions(+), 54 deletions(-) delete mode 100644 internal/bundle/installer_reconcile_rollback.go diff --git a/.github/coverage-floors.txt b/.github/coverage-floors.txt index b309bb94f..d0c1af1b9 100644 --- a/.github/coverage-floors.txt +++ b/.github/coverage-floors.txt @@ -59,6 +59,7 @@ internal/database/vectorsearch 94 # measured 96.9 2026-10-10 internal/deploy 53 # measured 55.2 2026-10-05 internal/deploy/archguard 76 # measured 78.6 2026-10-09 internal/deploy/bluegreen 44 # measured 46.3 2026-10-05 +internal/deploy/pathmap 0 # measured 0.0 2026-10-29 (no test files yet) internal/deploy/pintest 57 # measured 59.5 2026-10-13 (P7-HYG-49) internal/deploy/progress 86 # measured 88.8 2026-10-12 internal/deprecation 75 # measured 77.3 2026-10-05 @@ -80,6 +81,7 @@ internal/generate 65 # measured 67.5 2026-10-05 internal/hasura 87 # measured 89.1 2026-10-05 internal/health 26 # measured 28.1 2026-10-05 internal/hostcase 95 # measured 100.0 2026-10-17 (D-0881) +internal/hostpin 0 # measured 0.0 2026-10-29 (no test files yet) internal/httpapi 93 # measured 95.1 2026-10-11 (P7-SURF-02) internal/httptimeout 98 # measured 100.0 2026-10-05 internal/importer 47 # measured 49.7 2026-10-12 @@ -135,6 +137,7 @@ internal/plugin/verify 85 # measured 87.2 2026-10-05 internal/plugin/verify/signature 95 # measured 99.1 2026-10-13 (P7-PLUG-23) internal/plugin/verify/signature/signaturetest 0 # measured 0.0 2026-10-13; test-only key-table swap helper, exercised from other packages' tests (P7-PLUG-23) internal/pluginname 85 # measured 90.0 2026-10-26 (D-0602) +internal/pluginperm 48 # measured 50.0 2026-10-29 internal/portable 85 # measured 87.5 2026-10-05 internal/ports 9 # measured 11.5 2026-10-05 internal/postgres 49 # measured 51.4 2026-10-05 diff --git a/internal/bundle/installer_reconcile_rollback.go b/internal/bundle/installer_reconcile_rollback.go deleted file mode 100644 index 6e686850f..000000000 --- a/internal/bundle/installer_reconcile_rollback.go +++ /dev/null @@ -1,54 +0,0 @@ -package bundle - -// Purpose: undo fresh members after a refused reconcile without dropping kept schemas. -// Inputs: pre-install membership and ledger, completed install result. -// Outputs: rollback result and restored ledger records. Failed removals stay recorded. -import ( - "context" - "io" - "strings" - - "github.com/nself-org/cli/internal/bundle/ledger" - "github.com/nself-org/cli/internal/plugin" -) - -func rollbackReconcile(ctx context.Context, store *ledger.Store, dir string, before map[string]string, prior ledger.Ledger, opts InstallOpts, result *InstallResult, out io.Writer) error { - var fresh []string - for _, name := range result.Installed { - if _, existed := before[strings.ToLower(name)]; !existed { - fresh = append(fresh, name) - } - } - result.RolledBack = rollbackInstalled(ctx, opts.Config, plugin.UndoInstall, dir, fresh, out) - removed := make(map[string]bool) - for _, name := range result.RolledBack { - removed[strings.ToLower(name)] = true - } - return store.Update(func(l *ledger.Ledger) error { - // Restore the pre-install ledger, retaining any fresh member whose cleanup - // failed so that it remains discoverable for a later remove. - current := l.Clone() - *l = prior.Clone() - // Existing members may have been upgraded; retain their current facts - // while restoring the ownership references from before this install. - for key, record := range l.Plugins { - if now, ok := current.Plugins[key]; ok { - record.Tier, record.Version, record.Checksum = now.Tier, now.Version, now.Checksum - l.Plugins[key] = record - } - } - for _, name := range fresh { - key := strings.ToLower(name) - if removed[key] { - continue - } - if record, ok := current.Plugins[key]; ok { - l.Plugins[key] = record - for _, slug := range record.InstalledBy { - l.Bundles[slug] = current.Bundles[slug] - } - } - } - return nil - }) -} From 3c58cb7b0d94e64bda60f90a1cb9ce57fc838563 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 18:18:34 -0400 Subject: [PATCH 4/8] fix(ci): keep POSIX-only test assumptions off Windows - docker compose upgrade test drives a shell-script fake docker, like its neighbours: skip on Windows - first-use pin test asserts the 0600 mode only where permission bits exist --- internal/controlplane/promote/verify/trust_pin_test.go | 4 +++- internal/docker/compose_upgrade_test.go | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/controlplane/promote/verify/trust_pin_test.go b/internal/controlplane/promote/verify/trust_pin_test.go index e6a26c937..c1f1004a8 100644 --- a/internal/controlplane/promote/verify/trust_pin_test.go +++ b/internal/controlplane/promote/verify/trust_pin_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -27,7 +28,8 @@ func TestVerifyPinsOriginOnFirstUse(t *testing.T) { if err != nil || !strings.Contains(string(raw), "\ngit@github.com:o/r.git\n") { t.Fatalf("pin file = %q, %v", raw, err) } - if info, _ := os.Stat(pinFile(r.home)); info.Mode().Perm() != 0o600 { + // Windows has no owner-only permission bits (it reports 0666). + if info, _ := os.Stat(pinFile(r.home)); runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { t.Errorf("pin file mode %v, want 0600", info.Mode().Perm()) } if len(notes) != 1 || !strings.Contains(notes[0], "git@github.com:o/r.git") || len(warns) != 0 { diff --git a/internal/docker/compose_upgrade_test.go b/internal/docker/compose_upgrade_test.go index dc70d8fd5..4fbd3d96b 100644 --- a/internal/docker/compose_upgrade_test.go +++ b/internal/docker/compose_upgrade_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -11,6 +12,9 @@ import ( // Model Compose's image reuse contract: a tagged image survives an up unless // --build is supplied. The installed plugin context has already been upgraded. func TestComposeUpRebuildsInstalledPlugin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake docker is a POSIX shell script") + } for _, services := range [][]string{nil, {"plugin"}} { name := "all services" if len(services) > 0 { From 6c4a21fa93ce4240cde423224d41e3af29423c53 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 18:47:43 -0400 Subject: [PATCH 5/8] fix(ci): give the next-step cases a deadline a loaded Windows runner can meet Only the slow stub (sleeps 10s) is meant to reach the production 3s deadline. --- cmd/commands/next_step_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/commands/next_step_test.go b/cmd/commands/next_step_test.go index 2635aaae6..4aa65730c 100644 --- a/cmd/commands/next_step_test.go +++ b/cmd/commands/next_step_test.go @@ -154,7 +154,13 @@ func TestNextStep(t *testing.T) { t.Run(tc.stage+"_"+tc.mode, func(t *testing.T) { nextStepFixture(t, tc.stage) t.Setenv("NEXTSTEP_DOCKER", tc.mode) - ctx, cancel := context.WithTimeout(context.Background(), nextStepTimeout) + // Only the slow stub (it sleeps 10s) may reach the production deadline; + // a loaded Windows runner needs far more than 3s to start the helper. + timeout := 60 * time.Second + if tc.mode == "slow" { + timeout = nextStepTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if got := nextStep(ctx); got != "Next: nself "+tc.want { t.Errorf("got %q, want %q", got, tc.want) From ff090fd3bdf165e8ec3c4c5b93491867d8205a1b Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 20:04:19 -0400 Subject: [PATCH 6/8] docs(wiki): regenerate MCP-Tools and error-codes pages that main left stale --- .github/wiki/MCP-Tools.md | 123 ++++++++++++++++++------------------ .github/wiki/error-codes.md | 2 +- 2 files changed, 62 insertions(+), 63 deletions(-) diff --git a/.github/wiki/MCP-Tools.md b/.github/wiki/MCP-Tools.md index 6c42572fb..e0942d416 100644 --- a/.github/wiki/MCP-Tools.md +++ b/.github/wiki/MCP-Tools.md @@ -239,57 +239,57 @@ The tools `nself mcp` serves in v1.5 mode, one per exposed command of the machin | `nself_init` | Initialize a new nSelf project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22561) | | `nself_init_template` | Browse and publish full-stack app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22725) | | `nself_init_template_info` | Show detail for a single template | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22788) | -| `nself_init_template_list` | List available app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22900) | -| `nself_init_template_publish` | Submit a template to the nSelf registry | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23074) | -| `nself_init_template_update` | Apply incremental migrations for the installed template | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23196) | -| `nself_license` | Manage license keys for nSelf product bundles | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23259) | -| `nself_license_account` | Manage your nSelf account, sessions, licenses, team, and devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23322) | -| `nself_license_account_devices` | List and revoke registered devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23400) | -| `nself_license_account_licenses` | List and manage account licenses | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23480) | -| `nself_license_account_status` | Show current account summary | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23576) | -| `nself_license_account_team` | Manage team members | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23654) | -| `nself_license_add` | Add one or more license keys | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23724) | -| `nself_license_clear` | Remove all saved license keys | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23798) | -| `nself_license_health` | Validate format, ping server, and report cache integrity | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23909) | -| `nself_license_import` | Import a previously exported license cache file | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23976) | -| `nself_license_list` | Show all configured licenses (alias for status) | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24034) | -| `nself_license_migrate` | Migrate legacy license key to a nSelf account | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24160) | -| `nself_license_refresh` | Force-refresh license validation against ping.nself.org | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24336) | -| `nself_license_remove` | Remove a license key by value or product name | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24442) | -| `nself_license_restore` | Reactivate dormant plugins with a new license key | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24551) | -| `nself_license_revalidate` | Force a fresh validation against ping.nself.org and update the cache | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24663) | -| `nself_license_revoke` | Mark local license as revoked and wipe the stored key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24710) | -| `nself_license_set` | Replace all keys with a single key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24813) | -| `nself_license_show` | Display license tier, bundles, plugins, and cache expiry | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24930) | -| `nself_license_simulate_offline` | Simulate being offline for N days (testing only) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25005) | -| `nself_license_status` | Show all configured licenses and plugin coverage | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25083) | -| `nself_license_upgrade` | Open pricing page in browser | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25209) | -| `nself_license_validate` | Validate key against ping.nself.org | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25257) | -| `nself_logs` | View and filter service logs | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25307) | -| `nself_mcp_setup` | Print or write the MCP client config and AGENTS.md for this project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25467) | -| `nself_remove` | Remove an installed plugin or bundle | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25754) | -| `nself_reset` | Stop containers, remove all data volumes, and clean generated files | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25918) | -| `nself_restart` | Smart restart with config change detection | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26042) | -| `nself_start` | Boot your nSelf stack | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26234) | -| `nself_status` | Show health status of all services | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26401) | -| `nself_status_health` | Health check management with continuous monitoring | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26530) | -| `nself_status_health_check` | Run all health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26649) | -| `nself_status_health_config` | Show health check settings | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26736) | -| `nself_status_health_endpoint` | Check an HTTP endpoint | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26800) | -| `nself_status_health_history` | Show last 20 health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26862) | -| `nself_status_health_service` | Check a single service | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26964) | -| `nself_status_urls` | Display all service URLs with route conflict detection | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27026) | -| `nself_stop` | Gracefully shut down all services or specific services | `destructive` | with `--volumes` | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27479) | -| `nself_update` | Update the nSelf CLI and admin UI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27630) | -| `nself_update_images` | Refresh Docker image digests from registries | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27913) | -| `nself_update_project_detect` | Detect v1 artifacts in the current project | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28004) | -| `nself_update_project_from_bash` | Migrate a v0.9.9 Bash-era project to the current nSelf CLI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28056) | -| `nself_update_project_from_v099` | Migrate v0.9.9 home-level state (license key, channel, ssh keys) to v1.x layout | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28119) | -| `nself_update_project_rollback` | Restore a v1 backup created by migrate run | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28182) | -| `nself_update_project_run` | Migrate v1 project to v2 | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28304) | -| `nself_update_stack` | Upgrade the stack with a verified backup and resumable steps | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28356) | -| `nself_update_upgrade` | Upgrade the nSelf CLI (detects install method) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28508) | -| `nself_update_verify_sbom` | Verify the SBOM signature for a CLI release | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28594) | +| `nself_init_template_list` | List available app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22909) | +| `nself_init_template_publish` | Submit a template to the nSelf registry | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23092) | +| `nself_init_template_update` | Apply incremental migrations for the installed template | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23214) | +| `nself_license` | Manage license keys for nSelf product bundles | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23277) | +| `nself_license_account` | Manage your nSelf account, sessions, licenses, team, and devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23340) | +| `nself_license_account_devices` | List and revoke registered devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23418) | +| `nself_license_account_licenses` | List and manage account licenses | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23498) | +| `nself_license_account_status` | Show current account summary | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23594) | +| `nself_license_account_team` | Manage team members | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23672) | +| `nself_license_add` | Add one or more license keys | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23742) | +| `nself_license_clear` | Remove all saved license keys | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23816) | +| `nself_license_health` | Validate format, ping server, and report cache integrity | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23927) | +| `nself_license_import` | Import a previously exported license cache file | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23994) | +| `nself_license_list` | Show all configured licenses (alias for status) | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24052) | +| `nself_license_migrate` | Migrate legacy license key to a nSelf account | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24178) | +| `nself_license_refresh` | Force-refresh license validation against ping.nself.org | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24354) | +| `nself_license_remove` | Remove a license key by value or product name | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24460) | +| `nself_license_restore` | Reactivate dormant plugins with a new license key | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24569) | +| `nself_license_revalidate` | Force a fresh validation against ping.nself.org and update the cache | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24681) | +| `nself_license_revoke` | Mark local license as revoked and wipe the stored key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24728) | +| `nself_license_set` | Replace all keys with a single key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24831) | +| `nself_license_show` | Display license tier, bundles, plugins, and cache expiry | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24948) | +| `nself_license_simulate_offline` | Simulate being offline for N days (testing only) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25023) | +| `nself_license_status` | Show all configured licenses and plugin coverage | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25101) | +| `nself_license_upgrade` | Open pricing page in browser | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25227) | +| `nself_license_validate` | Validate key against ping.nself.org | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25275) | +| `nself_logs` | View and filter service logs | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25325) | +| `nself_mcp_setup` | Print or write the MCP client config and AGENTS.md for this project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25485) | +| `nself_remove` | Remove an installed plugin or bundle | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25772) | +| `nself_reset` | Stop containers, remove all data volumes, and clean generated files | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25936) | +| `nself_restart` | Smart restart with config change detection | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26060) | +| `nself_start` | Boot your nSelf stack | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26252) | +| `nself_status` | Show health status of all services | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26419) | +| `nself_status_health` | Health check management with continuous monitoring | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26548) | +| `nself_status_health_check` | Run all health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26667) | +| `nself_status_health_config` | Show health check settings | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26754) | +| `nself_status_health_endpoint` | Check an HTTP endpoint | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26818) | +| `nself_status_health_history` | Show last 20 health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26880) | +| `nself_status_health_service` | Check a single service | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26982) | +| `nself_status_urls` | Display all service URLs with route conflict detection | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27044) | +| `nself_stop` | Gracefully shut down all services or specific services | `destructive` | with `--volumes` | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27497) | +| `nself_update` | Update the nSelf CLI and admin UI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27648) | +| `nself_update_images` | Refresh Docker image digests from registries | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27931) | +| `nself_update_project_detect` | Detect v1 artifacts in the current project | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28022) | +| `nself_update_project_from_bash` | Migrate a v0.9.9 Bash-era project to the current nSelf CLI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28074) | +| `nself_update_project_from_v099` | Migrate v0.9.9 home-level state (license key, channel, ssh keys) to v1.x layout | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28137) | +| `nself_update_project_rollback` | Restore a v1 backup created by migrate run | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28200) | +| `nself_update_project_run` | Migrate v1 project to v2 | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28322) | +| `nself_update_stack` | Upgrade the stack with a verified backup and resumable steps | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28374) | +| `nself_update_upgrade` | Upgrade the nSelf CLI (detects install method) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28526) | +| `nself_update_verify_sbom` | Verify the SBOM signature for a CLI release | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28612) | ## Legacy aliases @@ -300,16 +300,15 @@ Deprecated names from v1.4, removed in v1.6.0. Each maps its parameters onto the | `nself_env_list` | Deprecated: use nself_config_env_list; removed in v1.6.0. List available environments. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22132) | | `nself_get_permissions` | Deprecated: use nself_db_hasura_permissions; removed in v1.6.0. Read Hasura table permissions. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22183) | | `nself_get_schema` | Deprecated: use nself_db_hasura_schema; removed in v1.6.0. Read the Hasura GraphQL schema. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22235) | -| `nself_plugin_install` | Deprecated: use nself_add; removed in v1.6.0. Install a plugin or bundle. | `remote` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25539) | -| `nself_plugin_list` | Deprecated: use nself_config_plugins_list; removed in v1.6.0. List available and installed plugins. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25662) | -| `nself_run_migration` | Deprecated: removed in v1.5. nself_run_migration ran raw SQL outside the migration ledger and is removed in v1.5; use nself db migrate create then nself db migrate up. | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26129) | -| `nself_service_list` | Deprecated: use nself_config_services_list; removed in v1.6.0. List all optional services with enabled/disabled status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26158) | -| `nself_urls` | Deprecated: use nself_status_urls; removed in v1.6.0. Display all service URLs with route conflict detection. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28657) | -| `sentry_incidents_ack` | Deprecated: use nself_sentry_incidents_ack; removed in v1.6.0. Acknowledge an ɳSentry incident. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29077) | -| `sentry_incidents_list` | Deprecated: use nself_sentry_incidents_list; removed in v1.6.0. List ɳSentry incidents. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29105) | -| `sentry_monitors_add` | Deprecated: use nself_sentry_monitors_add; removed in v1.6.0. Add an ɳSentry uptime monitor. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29130) | -| `sentry_monitors_list` | Deprecated: use nself_sentry_monitors_list; removed in v1.6.0. List ɳSentry uptime monitors. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29170) | -| `sentry_status` | Deprecated: use nself_sentry_status; removed in v1.6.0. Read the public ɳSentry status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29190) | +| `nself_plugin_install` | Deprecated: use nself_add; removed in v1.6.0. Install a plugin or bundle. | `remote` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25557) | +| `nself_plugin_list` | Deprecated: use nself_config_plugins_list; removed in v1.6.0. List available and installed plugins. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25680) | +| `nself_run_migration` | Deprecated: removed in v1.5. nself_run_migration ran raw SQL outside the migration ledger and is removed in v1.5; use nself db migrate create then nself db migrate up. | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26147) | +| `nself_service_list` | Deprecated: use nself_config_services_list; removed in v1.6.0. List all optional services with enabled/disabled status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26176) | +| `nself_urls` | Deprecated: use nself_status_urls; removed in v1.6.0. Display all service URLs with route conflict detection. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28675) | +| `sentry_incidents_ack` | Deprecated: use nself_sentry_incidents_ack; removed in v1.6.0. Acknowledge an ɳSentry incident. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29095) | +| `sentry_incidents_list` | Deprecated: use nself_sentry_incidents_list; removed in v1.6.0. List ɳSentry incidents. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29123) | +| `sentry_monitors_add` | Deprecated: use nself_sentry_monitors_add; removed in v1.6.0. Add an ɳSentry uptime monitor. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29148) | +| `sentry_monitors_list` | Deprecated: use nself_sentry_monitors_list; removed in v1.6.0. List ɳSentry uptime monitors. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29188) | +| `sentry_status` | Deprecated: use nself_sentry_status; removed in v1.6.0. Read the public ɳSentry status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29208) | **Plugin commands.** Installed plugins add tools by the same `ToolName` rule. This page is generated from the builtin registry alone, so it never lists them; each plugin's wiki page lists its own in its machine-surface region, for example the [CI plugin page](https://github.com/nself-org/plugins/wiki/CI). -**Plugin commands.** Installed plugins add tools by the same `ToolName` rule. This page is generated from the builtin registry alone, so it never lists them; each plugin's wiki page lists its own in its machine-surface region, for example the [CI plugin page](https://github.com/nself-org/plugins/wiki/CI). Plugin tools are advertised on the MCP surface like any other command, but a call that arrives while a secret bundle was restored is refused with `E421` because plugins do not yet support the secret-handoff protocol (D-0651); a machine-invoked plugin call that carries no secrets runs normally. diff --git a/.github/wiki/error-codes.md b/.github/wiki/error-codes.md index 131692e6f..47028ed49 100644 --- a/.github/wiki/error-codes.md +++ b/.github/wiki/error-codes.md @@ -233,7 +233,7 @@ These errors come from the command line itself: usage, JSON support, destructive | E407 | 1 | Builtin family not mounted | This builtin command family is disabled or was removed from the local installation, so its commands are not mounted. | Run nself add to restore the builtin family. | | E410 | 1 | Command removed in v1.5 | This command was removed in nSelf v1.5 and has no replacement. | Read the message for what to use instead, or run nself --help to see the current commands. | | E420 | 1 | Invalid machine request | The request sent over MCP or HTTP does not match the command's parameters. | Send only the args, flags and confirm members the command's params schema lists, with the right types. | -| E421 | 1 | Command not exposed on machine surfaces | This command is not served over MCP or HTTP, or the secret values passed to a machine-invoked child are unusable (bad bundle, an unused or reused value); the reason is in the cause. | Run the command from a terminal, or call a command that is exposed. | +| E421 | 1 | Command not exposed on machine surfaces | This command is not served over MCP or HTTP, the secret values passed to a machine-invoked child are unusable (bad bundle, an unused or reused value), or a plugin was invoked while a secret bundle was restored; the reason is in the cause. | Run the command from a terminal, or call a command that is exposed. | | E422 | 2 | Command produced no single JSON document | The command ran but its standard output was not exactly one JSON document. | Run the same command in a terminal with --json and read its output; report the command if it prints text in JSON mode. | | E423 | 2 | Command invocation timed out | The command did not finish before the invocation timeout and was stopped. | Raise the server invocation timeout, or use a command form that returns sooner. | | E424 | 4 | Confirmation does not match | The confirm value is not a live server-issued nonce or plan id for this exact request: it was reused, expired, unknown, computed by the client, or issued for a different request. | Send the request again without confirm, read the confirmation it returns, and repeat the identical request with that confirm value. | From f070eb0a63ba8f30b0c7c6de11b291f2c2dc5c8c Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 20:20:18 -0400 Subject: [PATCH 7/8] fix(ci): make two new tests honest on Windows - entitlement inode test: compare file identity through an open handle (os.Stat resolves ids lazily by path on Windows) - archive snapshot test: skip on Windows, where an open file cannot be unlinked --- .../entitlement/atomic_refresh_test.go | 21 +++++++++++++++++-- internal/plugin/archive_snapshot_test.go | 7 +++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/license/entitlement/atomic_refresh_test.go b/internal/license/entitlement/atomic_refresh_test.go index 349af7499..80a9608b9 100644 --- a/internal/license/entitlement/atomic_refresh_test.go +++ b/internal/license/entitlement/atomic_refresh_test.go @@ -17,11 +17,11 @@ func TestUnmountedRefreshReplacesInode(t *testing.T) { if err := writeAtomic(path, []byte("first")); err != nil { t.Fatal(err) } - before, _ := os.Stat(path) + before := fileIdentity(t, path) if err := writeAtomic(path, []byte("second")); err != nil { t.Fatal(err) } - after, _ := os.Stat(path) + after := fileIdentity(t, path) if os.SameFile(before, after) { t.Fatal("unmounted refresh rewrote the live inode") } @@ -81,3 +81,20 @@ func TestMountProbePolicy(t *testing.T) { }) } } + +// fileIdentity stats through an open handle: on Windows os.Stat resolves the +// file id lazily, by path, when SameFile runs, so two Stat results of one path +// always compare equal even after the file was replaced. +func fileIdentity(t *testing.T, path string) os.FileInfo { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + t.Fatal(err) + } + return info +} diff --git a/internal/plugin/archive_snapshot_test.go b/internal/plugin/archive_snapshot_test.go index 9c272ca39..a7a6bdd63 100644 --- a/internal/plugin/archive_snapshot_test.go +++ b/internal/plugin/archive_snapshot_test.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "os" "path/filepath" + "runtime" "testing" ) @@ -17,6 +18,12 @@ import ( // read that handle, so replacing the path after the open cannot change what // gets verified or extracted. func TestArchiveSnapshotSurvivesPathReplacement(t *testing.T) { + if runtime.GOOS == "windows" { + // Go opens files without FILE_SHARE_DELETE, so the path of an open + // archive cannot be unlinked there and the replacement below would + // rewrite the very file the handle reads. The contract is POSIX-only. + t.Skip("an open file cannot be unlinked on Windows") + } var archive bytes.Buffer gz := gzip.NewWriter(&archive) tw := tar.NewWriter(gz) From 0438bafd474c77b2016a7b19073a34605f0e5a5a Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 20:36:38 -0400 Subject: [PATCH 8/8] fix(ci): coverage floor for internal/plugin/autoinstall --- .github/coverage-floors.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/coverage-floors.txt b/.github/coverage-floors.txt index 1a5de92fc..dc2db0399 100644 --- a/.github/coverage-floors.txt +++ b/.github/coverage-floors.txt @@ -128,6 +128,7 @@ internal/oplock 74 # measured 79.3 2026-10-05 internal/oplock/oplockcmd 88 # measured 93.5 2026-10-05 internal/output 96 # measured 98.2 2026-10-05 internal/plugin 50 # measured 53.0 2026-10-05 +internal/plugin/autoinstall 0 # measured 0.0 2026-10-30 (no test files yet) internal/plugin/catalog 81 # measured 83.0 2026-10-13 internal/plugin/count 98 # measured 100.0 2026-10-05 internal/plugin/lifecycle 74 # measured 76.4 2026-10-05