Skip to content
Merged
68 changes: 47 additions & 21 deletions cmd/commands/builtin_families.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,31 +69,57 @@ func mountBuiltinFamiliesFromList(root *cobra.Command) {
if !builtinEnabled(f.slug) {
continue
}
alreadyMounted := false
for _, existing := range root.Commands() {
if existing.Annotations[mount.AnnSource] == sourceBuiltin && existing.Annotations[mount.AnnPlugin] == f.slug {
alreadyMounted = true
break
}
mountBuiltinFamily(root, f)
}
}

// mountBuiltinFamily mounts one family on root and returns its node, or nil
// when the family is already mounted or builds nothing.
func mountBuiltinFamily(root *cobra.Command, f builtinFamily) *cobra.Command {
for _, existing := range root.Commands() {
if existing.Annotations[mount.AnnSource] == sourceBuiltin && existing.Annotations[mount.AnnPlugin] == f.slug {
return nil
}
if alreadyMounted {
continue
}
node := f.build()
if node == nil {
return nil
}
annotateBuiltin(node, f.slug)
if node.GroupID == "" {
// compat.V15(P7-CANON-19): account group -> plugin commands group
if compat.V15() {
node.GroupID = groupPlugins
} else {
node.GroupID = groupAccount
}
node := f.build()
if node == nil {
continue
}
ensureBuiltinGroups(root, node)
root.AddCommand(node)
return node
}

// mountDisabledBuiltinFamilies mounts the builtin families whose disable
// marker exists, for the duration of a registry build only, and returns the
// undo that removes them again. The canon entries, data types and v1.5-only
// envelope paths of a family are declared whether or not it is installed, so
// the registry (and everything generated from it) must not depend on the
// developer's plugin directory; the runtime tree keeps the family unmounted
// (`nself <slug>` still exits E407) (D-0863).
func mountDisabledBuiltinFamilies(root *cobra.Command) func() {
var added []*cobra.Command
for _, f := range builtinFamilies {
if builtinEnabled(f.slug) {
continue // prepareTree mounts it
}
annotateBuiltin(node, f.slug)
if node.GroupID == "" {
// compat.V15(P7-CANON-19): account group -> plugin commands group
if compat.V15() {
node.GroupID = groupPlugins
} else {
node.GroupID = groupAccount
}
if node := mountBuiltinFamily(root, f); node != nil {
added = append(added, node)
}
}
return func() {
for _, c := range added {
root.RemoveCommand(c)
}
ensureBuiltinGroups(root, node)
root.AddCommand(node)
}
}

Expand Down
5 changes: 4 additions & 1 deletion cmd/commands/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,10 @@ func runDNSSetup(cmd *cobra.Command, args []string) error {
if err != nil {
if errors.Is(err, sysexec.ErrSkippedHeadless) {
ui.Warn("Headless mode (NSELF_HEADLESS=1 or CI=1): /etc/hosts not changed. Run `nself dns-setup` on a machine with admin rights, or set NSELF_HEADLESS=0.")
return nil
if jsonEnvelopeOn(cmd) {
setData(cmd, dataResult{OK: false}) // skipped: the envelope must not claim /etc/hosts was updated
}
return nil // start_config.go relies on skip-and-continue
}
if errors.Is(err, ssl.ErrSudoRequired) {
if runtime.GOOS == "darwin" {
Expand Down
14 changes: 14 additions & 0 deletions cmd/commands/dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,17 @@ func TestRerunWithOsascript_NonInteractiveReturnsEarly(t *testing.T) {
t.Error("expected error for cancelled context, got nil")
}
}

// TestDNSSetupHeadlessSkipEnvelope: under headless the /etc/hosts write is
// skipped, the command still exits 0 (start_config.go continues), but the v1.5
// envelope must say ok:false instead of claiming success (D-0830).
func TestDNSSetupHeadlessSkipEnvelope(t *testing.T) {
run := runPilot(t, cfgFixtureProject(t), true, []string{"NSELF_HEADLESS=1"}, "config", "trust", "dns", "--json")
if run.code != 0 {
t.Fatalf("headless skip must exit 0, got %d\n%s", run.code, run.stderr)
}
data := validateEnvelope(t, run.stdout, "config trust dns")
if data["ok"] != false {
t.Errorf("headless-skipped dns setup: data = %v, want ok:false", data)
}
}
10 changes: 0 additions & 10 deletions cmd/commands/invocation.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,6 @@ func locked(orig func(*cobra.Command, []string) error) func(*cobra.Command, []st
return oplockcmd.Wrap(func(c *cobra.Command) (*cmdregistry.Command, error) { return jsonEntryFor(c) }, orig)
}

// guardedPre checks before hooks can fetch, cache, or change directories.
func guardedPre(orig func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
if err := enterInvocation(cmd); err != nil {
return err
}
return orig(cmd, args)
}
}

// enterInvocation records the invocation and refuses unsupported --json. It is
// idempotent: the pre-run hook and RunE both call it.
func enterInvocation(cmd *cobra.Command) error {
Expand Down
37 changes: 37 additions & 0 deletions cmd/commands/invocation_installed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package commands

// Pre-run entry of the invocation decorator. Installed plugin commands take a
// separate path: the mount parses no flags, so the invocation record is built
// from the forwarded args.

import (
"github.com/nself-org/cli/internal/output"
"github.com/spf13/cobra"
)

// enterInstalledInvocation records the invocation of an installed plugin
// command. The mount parses no flags (DisableFlagParsing), so the --json the
// plugin receives is only in the forwarded args: the intent is read from them,
// otherwise a proxy that fails to start (binary missing, not executable) would
// be reported as text although the caller asked for JSON (D-0614). Nothing is
// refused here: the plugin owns its output.
func enterInstalledInvocation(cmd *cobra.Command, args []string) error {
output.SetInvocation(invokedKey(cmd), output.JSONRequestedFromArgs(args))
return imageSwitchGuard(cmd)
}

// guardedPre checks before hooks can fetch, cache, or change directories.
func guardedPre(orig func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
if cmd.Annotations["nself.mount.source"] == "installed" {
if err := enterInstalledInvocation(cmd, args); err != nil {
return err
}
return orig(cmd, args)
}
if err := enterInvocation(cmd); err != nil {
return err
}
return orig(cmd, args)
}
}
4 changes: 4 additions & 0 deletions cmd/commands/machine_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ func buildMachineRegistry() (*cmdregistry.Registry, error) {
// installed plugins) were mounted under the process's own mode, which sets
// their group; lift them off and mount afresh under v1.5.
lifted := detachMounts(RootCmd)
// A disabled builtin family keeps its canon entries, so it is mounted for
// the build as in buildRegistry (D-0863); the detachMounts after the build
// removes it again.
mountDisabledBuiltinFamilies(RootCmd)
undo := prepareTree(RootCmd, true, false)
specs, problems := mount.Discover(resolvePluginDir(), canonTable.Verbs)
mountInstalled(RootCmd, specs, problems)
Expand Down
9 changes: 4 additions & 5 deletions cmd/commands/ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,8 @@ func opsPreflight(ctx context.Context, workdir, host, composePath string) error
inv := &controlplane.Inventory{Environments: map[string]controlplane.Environment{
opsEnvName: {Name: opsEnvName, Kind: "remote", Servers: []controlplane.Server{server}},
}}
files, err := controlplane.ComposeFilesForDeploy(composePath)
if err != nil {
return err
}
return opsControlplaneCheck(ctx, inv, opsEnvName, files)
// The ops push ships only composePath (deploy.DeployViaSsh), so that is the
// only file whose images the host must be able to run; plugin fragments of
// the project are not deployed here (D-0614).
return opsControlplaneCheck(ctx, inv, opsEnvName, []string{composePath})
}
30 changes: 30 additions & 0 deletions cmd/commands/ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ package commands
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -129,3 +131,31 @@ func TestOpsPreflight_RejectsBadHost(t *testing.T) {
t.Fatal("want error for an invalid host spec")
}
}

// TestOpsPreflight_ChecksOnlyShippedCompose: the ops push ships the one built
// compose file, so plugin fragments listed in the project manifest are not
// handed to the arch guard (D-0614).
func TestOpsPreflight_ChecksOnlyShippedCompose(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := os.MkdirAll(filepath.Join(dir, ".nself"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".nself", "compose-files.txt"), []byte("docker-compose.yml\ndocker-compose.plugin-x.yml\n"), 0o600); err != nil {
t.Fatal(err)
}
var got []string
orig := opsControlplaneCheck
opsControlplaneCheck = func(_ context.Context, _ *controlplane.Inventory, _ string, files []string) error {
got = files
return nil
}
t.Cleanup(func() { opsControlplaneCheck = orig })
compose := filepath.Join(dir, "docker-compose.yml")
if err := opsPreflight(context.Background(), dir, "deploy@ops.example.com:/opt/nself", compose); err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != compose {
t.Fatalf("arch guard files = %v, want only %s", got, compose)
}
}
37 changes: 37 additions & 0 deletions cmd/commands/plugin_mount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/nself-org/cli/internal/canon"
"github.com/nself-org/cli/internal/oplock"
"github.com/nself-org/cli/internal/output"
"github.com/nself-org/cli/internal/plugin"
"github.com/nself-org/cli/internal/plugin/mount"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -309,3 +310,39 @@ func TestDoctorPluginMount(t *testing.T) {
t.Fatalf("doctor: %+v", results)
}
}

// TestMountProxyFailureKeepsJSONIntent: an installed plugin command parses no
// flags, so the --json it was given must still be recorded as the invocation's
// JSON mode; otherwise a proxy that cannot start fails as plain text (D-0614).
func TestMountProxyFailureKeepsJSONIntent(t *testing.T) {
t.Setenv("HOME", t.TempDir())
cases := []struct {
args []string
json bool
}{
{[]string{"demo", "--json"}, true},
{[]string{"demo", "sub", "--json=true"}, true},
{[]string{"demo", "--json=false"}, false},
{[]string{"demo", "--", "--json"}, false},
{[]string{"demo"}, false},
}
for _, c := range cases {
r := &cobra.Command{Use: "nself", SilenceUsage: true, SilenceErrors: true}
r.AddGroup(&cobra.Group{ID: groupPlugins, Title: pluginsGroupTitle})
r.AddCommand(installedNode(mount.Spec{
Slug: "demo", Command: "demo", Binary: "nself-demo",
BinaryPath: filepath.Join(t.TempDir(), "missing", "nself-demo"),
Subcommands: []mount.Sub{{Path: []string{"sub"}}},
}))
installInvocationDecorator(r)
output.SetInvocation("", false)
r.SetArgs(c.args)
if err := r.Execute(); err == nil {
t.Fatalf("%v: the missing plugin binary must fail", c.args)
}
command, on, known := output.Invocation()
if !known || on != c.json || !strings.HasPrefix(command, "demo") {
t.Errorf("%v: invocation = (%q, json=%v, known=%v), want json=%v", c.args, command, on, known, c.json)
}
}
}
4 changes: 4 additions & 0 deletions cmd/commands/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ func buildRegistry(v15 bool) (*cmdregistry.Registry, error) {
if err != nil {
return nil, fmt.Errorf("command canon: %w", err)
}
// A disabled builtin family keeps its canon entries and data types, so the
// registry is built with every family mounted (D-0863).
undoFamilies := mountDisabledBuiltinFamilies(RootCmd)
defer undoFamilies()
undo := prepareTree(RootCmd, v15, false)
defer undo()
reg, err := cmdregistry.Build(RootCmd, c, jsonDataTypes, cmdregistry.BuildOptions{
Expand Down
50 changes: 50 additions & 0 deletions cmd/commands/registry_disabled_family_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package commands

// Purpose: a disabled builtin family (nself remove admin) must not break the
// v1.5 --json surface (D-0863). Runs the real binary: remove admin, then
// version --json must answer with one success envelope, and the family must
// stay unmounted (E407).
// Constraints: child process only; HOME is the test's temp dir.

import (
"strings"
"testing"
)

func TestDisabledBuiltinFamilyKeepsJSONSurface(t *testing.T) {
proj := cfgFixtureProject(t)
rm := runPilot(t, proj, true, nil, "remove", "admin")
if rm.code != 0 {
t.Fatalf("remove admin: exit %d\n%s%s", rm.code, rm.stdout, rm.stderr)
}
ver := runPilot(t, proj, true, nil, "version", "--json")
if ver.code != 0 {
t.Fatalf("version --json after remove admin: exit %d\n%s%s", ver.code, ver.stdout, ver.stderr)
}
validateEnvelope(t, ver.stdout, "version")
if strings.Contains(ver.stdout, "command registry") {
t.Fatalf("registry build error leaked into the envelope:\n%s", ver.stdout)
}
// The disabled family stays unmounted: E407, not a registry problem.
adm := runPilot(t, proj, true, nil, "admin", "--json")
if adm.code == 0 || !strings.Contains(adm.stdout, `"E407"`) {
t.Fatalf("admin --json after remove admin: exit %d, want E407\n%s%s", adm.code, adm.stdout, adm.stderr)
}
}

// The machine registry (nself mcp, nself invoke) is built apart from the
// runtime one; it must mount a disabled family too (D-0863), or the family's
// canon entries are all unresolved and mcp exits before serving.
func TestDisabledBuiltinFamilyKeepsMachineRegistry(t *testing.T) {
proj := cfgFixtureProject(t)
rm := runPilot(t, proj, true, nil, "remove", "admin")
if rm.code != 0 {
t.Fatalf("remove admin: exit %d\n%s%s", rm.code, rm.stdout, rm.stderr)
}
// stdin is closed, so a server that built its tools exits cleanly.
mcp := runPilot(t, proj, true, nil, "mcp")
out := mcp.stdout + mcp.stderr
if mcp.code != 0 || strings.Contains(out, "machine registry") || strings.Contains(out, "unresolved") {
t.Fatalf("mcp after remove admin: exit %d, want a clean start\n%s", mcp.code, out)
}
}
2 changes: 1 addition & 1 deletion cmd/commands/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func runStartFlow(cmd *cobra.Command, _ []string, out *startOutcome) error {
// ── AI auto-install (T-05-05) + Auto-build detection ──────────────
// Both extracted to start_checks.go (T-P6-E2-W1-S1-T3). Auto-build runs
// BEFORE the docker-compose.yml check because build creates it.
autoInstallAIIfNeeded(ctx)
autoInstallAIIfNeeded(ctx, projectDir)

entitlementRefreshed := false
if !opts.skipBuild {
Expand Down
Loading
Loading