Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/facts.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"schema_version": "1",
"sources": {
"openapi_sha256": "01ea16e2ac6cebd687983d758b80a9f1ddffe3b6a3a85bda4fc70ce077ca71b6",
"openapi_sha256": "2c0de95f09fd6b06b1031a9fc2ed8ebacfc635e975ca487c2803903ddb120e7f",
"registry_sha256": "37fe98fe4dfa26f0c8af15f5cc50e9a5b81f499976850ae2b6066c536e8fe6c9"
},
"verbs": [
Expand Down
1 change: 1 addition & 0 deletions .github/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -22238,6 +22238,7 @@
},
"/v1/commands/config/plugins/info": {
"post": {
"description": "An unresolved tier returns an error envelope with the cause. The legacy bare tier_reason and tier_error fields are not a successful HTTP response.",
"operationId": "nself_config_plugins_info",
"requestBody": {
"content": {
Expand Down
2 changes: 2 additions & 0 deletions .github/wiki/Command-Registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ Fields are in this order. Consumers must ignore fields they do not know: new fie

Declare `confirm`, `surface: cli-only`, and `secret_args` on the command row in its canon fragment. `confirm.flags` lists local or inherited boolean flags; an empty list is valid for nonce confirmation. A plan form names its boolean `flag` and string `id_flag`. A flag override may declare `cli_only: true` or `secret: true`, alongside the existing `output: stream` override for a follow-style flag. A deprecated shim receives none of these declarations. Installed plugin commands carry the same facts through their mount annotations. Invalid plugin confirmation is omitted with one E437 warning per plugin.

For `secrets set`, the machine invoker hands `secret_args` values to the child through its environment and a supplied VALUE of `"-"` is interpreted literally, subject to normal secret validation, instead of reading stdin. In a terminal, omitting VALUE or passing `-` reads stdin. Machine requests have no stdin, so a restored VALUE never uses that marker.

`cmdregistry.ToolName(path)` replaces spaces and hyphens with underscores and prefixes `nself_`; names must be unique, at most 64 characters, and contain only lowercase letters, digits, and underscores. `RoutePath(path)` separates path words under `/v1/commands/`. `EffectiveSideEffect` takes the highest class from the command and set flag overrides. `EffectiveOutput` returns `stream` when a set flag declares `output: stream`, otherwise the command output.

### Canon status
Expand Down
2 changes: 2 additions & 0 deletions .github/wiki/MCP-Server.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ The `sse` and `http` transports of `nself mcp` check a bearer token on every req
2. The file `~/.nself/mcp-token` (source `file`). It must be a regular file (a symlink is refused), mode `0600` on macOS and Linux, and not empty. A file that group or others can read is refused with `E426`; the message names the path and `chmod 600`.
3. Neither. In v1.5 mode (`NSELF_V15=1`) the first loopback start generates 32 random bytes as 64 hex characters, creates `~/.nself` at `0700` if it is missing (an existing one is narrowed to `0700`, never widened; if that fails and group or others can write to it, the start is refused), writes the file at `0600` and prints its path and a `cat <path>` hint on standard error. The token value is never printed to any stream. Later starts read the file. In v1.4 mode (the default today) a start with no token serves an open loopback port and prints one warning.

Token lookup is read-only. If an existing `~/.nself` is group-writable (for example after umask `002`), run `chmod go-w ~/.nself` before starting the server; lookup refuses it with `E426` even when the token itself is `0600`. Run nself as the token file’s owner: `sudo nself` reading a user-owned token is also refused with `E426`, because the file owner must match the effective user.

Long-lived responses (SSE streams, a tool call that runs for minutes) are not cut by the read timeout: the request body is read under it, then the read deadline is cleared. A request without the right `Authorization: Bearer <token>` header gets `401 unauthorized` with `WWW-Authenticate: Bearer realm="nself-mcp"`. The comparison hashes both sides first, so it does not depend on the token length. With a token set, behaviour is the same in both modes. Both modes also read an existing token file, so a malformed or group-readable `~/.nself/mcp-token` stops a v1.4 start with `E426` too (the file is new in 1.5; nothing created it before). `stdio` needs no token. The invoker removes `NSELF_MCP_TOKEN` from the environment of the commands it runs.

## Transports and --host
Expand Down
2 changes: 1 addition & 1 deletion .github/wiki/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Use `nself plugin search --licensed <query>` to find Licensed plugins. The older

A plugin served as a Free and a Licensed pair (cron, notify) keeps the tier it was installed with. `nself plugin update notify` on a Free install stays Free even after you buy a licence, so a purchase never changes a running stack by itself. The same holds for `nself plugin install notify` over an installed copy and for a Bundle install that touches it. To move to the other tier on purpose, pass `--tier licensed` (or `--tier free`); `--tier licensed` still needs the licence.

The reason a tier was chosen is one of `installed`, `override`, `entitlement`, `only-tier` (the registry serves a single Licensed entry and no entitlement was looked up; install still checks the licence) or `default-free`. If the installed tier cannot be kept (the licence lapsed, or the registry now serves only the other tier), the update stops with E131 and changes nothing; pass `--tier` to choose. A failed entitlement lookup is reported as itself, never as a downgrade. In v1.4 mode an update keeps resolving by entitlement as before. In v1.5 mode `nself plugin info <name> --json` reports `tier` (`free` or `licensed`) and `tier_reason` (v1.4 mode ignores `--json`); a tier that cannot be decided, or a licence lookup that failed, is reported as `tier_reason: "unresolved"` with the cause in `tier_error`, never as the first registry entry or a default; `nself plugin update <name> --tier free|licensed` is the explicit switch.
The reason a tier was chosen is one of `installed`, `override`, `entitlement`, `only-tier` (the registry serves a single Licensed entry and no entitlement was looked up; install still checks the licence) or `default-free`. If the installed tier cannot be kept (the licence lapsed, or the registry now serves only the other tier), the update stops with E131 and changes nothing; pass `--tier` to choose. A failed entitlement lookup is reported as itself, never as a downgrade. In v1.4 mode an update keeps resolving by entitlement as before. In v1.5 mode `nself plugin info <name> --json` reports `tier` (`free` or `licensed`) and `tier_reason` (v1.4 mode ignores `--json`); a tier that cannot be decided, or a licence lookup that failed, returns an error envelope with the reason and cause in its message (`NSELF_JSON_LEGACY=1` keeps the bare `tier_reason: "unresolved"` and `tier_error` fields), never as the first registry entry or a default; `nself plugin update <name> --tier free|licensed` is the explicit switch.

## Compose fragment rules

Expand Down
2 changes: 1 addition & 1 deletion .github/wiki/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ These errors come from the command line itself: usage, JSON support, destructive
| <a id="e423"></a>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. |
| <a id="e424"></a>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. |
| <a id="e425"></a>E425 | 1 | Consent or plan-id flag set by the caller | Consent flags (such as --yes or --force) and plan-id flags belong to the invoker; a machine request cannot set them. | Remove the flag from the request and use the confirmation round trip: the server adds it once you confirm. |
| <a id="e426"></a>E426 | 3 | Machine token missing, invalid or insecure | The machine surface needs a bearer token it can trust: the token file is unreadable or readable by others, or a non-loopback bind lacks --allowed-host and a configured token. | Set NSELF_MCP_TOKEN or keep ~/.nself/mcp-token at mode 0600; for a non-loopback --host also pass --allowed-host <name>. |
| <a id="e426"></a>E426 | 3 | Machine token missing, invalid or insecure | The machine surface needs a bearer token it can trust: the token file is unreadable, readable by others, owned by another effective user (including sudo), or in a writable parent directory; or a non-loopback bind lacks --allowed-host and a configured token. | Run as the token file owner; set NSELF_MCP_TOKEN or keep ~/.nself/mcp-token at mode 0600 and run chmod go-w ~/.nself; for a non-loopback --host also pass --allowed-host <name>. |
| <a id="e428"></a>E428 | 1 | Host not allowed | The request Host is outside the server's allowed hosts and port. | Use the configured listener host or pass --allowed-host explicitly. |
| <a id="e429"></a>E429 | 1 | Origin not allowed | The request Origin is outside the server's allowed HTTP origins. | Use an allowed HTTP origin on the listener port. |
| <a id="e430"></a>E430 | 1 | Malformed HTTP request | The method, JSON body, content type, body size or Accept header is invalid. | POST a JSON machine request no larger than 1 MiB and accept application/json or application/x-ndjson. |
Expand Down
17 changes: 5 additions & 12 deletions cmd/commands/account_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/nself-org/cli/internal/output"
"os"
"strings"

Expand Down Expand Up @@ -63,6 +64,9 @@ func runAccountStatus(cmd *cobra.Command, _ []string) error {
if infoErr != nil {
// Fall back to cached data.
if jsonEnvelopeOn(cmd) {
if output.LegacyRequested() {
return emitDoc(cmd, auth.CachedAccountJSON(af), true)
}
return emitAccountStatus(cmd, accountCachedStatus(af, "cached"))
}
if jsonOut {
Expand Down Expand Up @@ -149,20 +153,9 @@ func printCachedAccount(af *auth.AuthFile) {
}

func printCachedAccountJSON(af *auth.AuthFile) error {
tier := af.Tier
if tier == "" {
tier = "free"
}
out := map[string]interface{}{
"email": af.Email,
"display_name": af.DisplayName,
"tier": tier,
"bundles": af.Bundles,
"cached": true,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
return enc.Encode(auth.CachedAccountJSON(af))
}

// ── account show (legacy alias — same as status) ─────────────────────────────
3 changes: 2 additions & 1 deletion cmd/commands/bundle_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package commands
import (
"encoding/json"
"fmt"
"github.com/nself-org/cli/internal/output"
"strings"

"github.com/nself-org/cli/internal/bundle"
Expand Down Expand Up @@ -116,7 +117,7 @@ func runBundleList(cmd *cobra.Command, _ []string) error {
}

if jsonEnvelopeOn(cmd) {
if rows == nil && !legacyBare() {
if rows == nil && !output.LegacyRequested() {
rows = []bundleListRow{} // the envelope data is an array; the bare legacy form printed null
}
return emitEnv(cmd, rows, true) // P7-SURF-13
Expand Down
88 changes: 88 additions & 0 deletions cmd/commands/command_log_lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package commands

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/nself-org/cli/internal/cmdlog"
"github.com/nself-org/cli/internal/errs"
"github.com/spf13/cobra"
)

func TestCommandLogLifecycleChild(t *testing.T) {
if os.Getenv("NSELF_TEST_LOG_CHILD") != "1" {
t.Skip("child fixture")
}
c := &cobra.Command{Use: "log-fixture", RunE: func(c *cobra.Command, args []string) error {
home, _ := os.UserHomeDir()
if _, err := os.Stat(filepath.Join(home, ".nself/logs/nself.log")); !os.IsNotExist(err) {
return fmt.Errorf("log was written before command completion")
}
mode, _ := c.Flags().GetString("mode")
switch mode {
case "error":
secret, _ := c.Flags().GetString("api-token")
return errs.New("E426", "fixture failure "+secret)
case "state":
return errs.Exit(12)
}
return nil
}}
c.Flags().String("api-token", "", "fixture")
c.Flags().String("mode", "", "fixture")
RootCmd.AddCommand(c)
os.Args = []string{"nself", "log-fixture", "--api-token", "FAKE-log-lifecycle-secret", "--mode", os.Getenv("NSELF_TEST_LOG_MODE")}
err := Execute()
os.Exit(errs.ExitCodeFor(err))
}

func TestCommandLogLifecycle(t *testing.T) {
for _, tc := range []struct {
mode string
code int
}{{"success", 0}, {"error", errs.ExitAuthError}, {"state", 12}} {
t.Run(tc.mode, func(t *testing.T) {
home := t.TempDir()
child := exec.Command(os.Args[0], "-test.run=^TestCommandLogLifecycleChild$")
child.Dir = t.TempDir()
child.Env = append(os.Environ(), "HOME="+home, "USERPROFILE="+home, "NSELF_V15=0", "NSELF_CMD_LOG_ENABLED=1", "NSELF_TEST_LOG_CHILD=1", "NSELF_TEST_LOG_MODE="+tc.mode)
err := child.Run()
code := 0
if err != nil {
if e, ok := err.(*exec.ExitError); ok {
code = e.ExitCode()
} else {
t.Fatal(err)
}
}
if code != tc.code {
t.Fatalf("exit = %d, want %d", code, tc.code)
}
raw, err := os.ReadFile(filepath.Join(home, ".nself/logs/nself.log"))
if err != nil {
t.Fatal(err)
}
if strings.Count(string(raw), "\n") != 1 {
t.Fatalf("want one entry, got %s", raw)
}
var entry cmdlog.Entry
if err := json.Unmarshal(raw, &entry); err != nil {
t.Fatal(err)
}
if entry.ExitCode != tc.code {
t.Fatalf("logged exit = %d, want %d", entry.ExitCode, tc.code)
}
if strings.Contains(string(raw), "FAKE-log-lifecycle-secret") {
t.Fatal("fixture secret reached command log")
}
if tc.mode == "error" && (!strings.Contains(entry.Error, "fixture failure") || !strings.Contains(entry.Error, "[REDACTED]")) {
t.Fatalf("error missing or not scrubbed: %s", raw)
}
})
}
}
9 changes: 8 additions & 1 deletion cmd/commands/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,14 @@ func invokedCommandPath(cmd *cobra.Command) string {

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main().
func Execute() error {
func Execute() (resultErr error) {
commandLogFinish = nil
defer func() {
if commandLogFinish != nil {
commandLogFinish(errs.ExitCodeFor(resultErr), resultErr)
commandLogFinish = nil
}
}()
// Secret values arrive by environment, not argv (D-0468): restore them first.
if err := restoreSecretArgv(); err != nil {
return err
Expand Down
22 changes: 22 additions & 0 deletions cmd/commands/json_account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,25 @@ func accountGoldenSample(path string, zero any) any {
}
return goldenSample(path, zero)
}

func TestAccountStatusLegacyCached(t *testing.T) {
want, err := os.ReadFile("testdata/json/account-status-cached-legacy.golden.json")
if err != nil {
t.Fatal(err)
}
compattest.Set(t, true)
t.Setenv(output.LegacyEnvVar, "1")
undo := prepareTreeWith(&canonTable, RootCmd, true, false)
defer undo()
home := isolateHome(t)
writeTestAuthFile(t, home, &auth.AuthFile{AccessToken: fixtureAccessToken, Email: "alice@example.test", Tier: "plus"})
accountServer(t, false)
out, err := runLicenseJSON(t, accountStatusCmd, nil)
if err != nil {
t.Fatal(err)
}
noSecrets(t, "legacy cached status", out)
if out != string(want) {
t.Fatalf("got %q, want %q", out, want)
}
}
16 changes: 15 additions & 1 deletion cmd/commands/json_extend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,23 @@ func TestServicesBodies(t *testing.T) {
})
t.Run("ps without a compose file is an error, not an empty document", func(t *testing.T) {
fakeProject(t, "PROJECT_NAME=smoke\n")
if _, err := runExt(t, extendCmd(t, "config", "services", "ps"), nil, map[string]string{}); err == nil {
out, err := runExt(t, extendCmd(t, "config", "services", "ps"), nil, map[string]string{})
if err == nil {
t.Fatal("ps without docker-compose.yml succeeded")
}
var envelope bytes.Buffer
if err := output.EmitError(output.Writer{Out: &envelope}, "config services ps", err); err != nil {
t.Fatal(err)
}
var doc struct {
Error struct{ Code, Message string }
}
if err := json.Unmarshal(envelope.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if doc.Error.Code != "E400" || !strings.Contains(doc.Error.Message, "docker-compose.yml") || out != "" {
t.Fatalf("missing-compose failure: output=%q envelope=%s", out, envelope.String())
}
})
t.Run("group answers with its subcommands", func(t *testing.T) {
out, err := runExt(t, extendCmd(t, "config", "services"), nil, map[string]string{})
Expand Down
4 changes: 4 additions & 0 deletions cmd/commands/license_envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package commands

import (
"errors"
"github.com/nself-org/cli/internal/output"

"github.com/nself-org/cli/internal/license"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -57,6 +58,9 @@ func newLicenseStatusData() licenseStatusData {
// emitLicenseShow writes the `license show` document in its legacy-compatible
// form; out is nil when no key is configured.
func emitLicenseShow(cmd *cobra.Command, out *licenseShowJSON) error {
if out == nil && output.LegacyRequested() {
return emitDoc(cmd, map[string]string{"error": "no license key configured"}, true)
}
doc := licenseShowData{Bundles: []string{}}
if out != nil {
doc = licenseShowData{Configured: true, KeyPrefix: out.KeyPrefix, Tier: out.Tier, Bundles: nonNilStrings(out.Bundles),
Expand Down
21 changes: 21 additions & 0 deletions cmd/commands/license_envelope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,24 @@ func TestLicenseMaskedInGoldens(t *testing.T) {
}
}
}

func TestLicenseShowLegacyNoKey(t *testing.T) {
pkg, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
newLicenseFixture(t)
t.Setenv(output.LegacyEnvVar, "1")
out, err := runLicenseJSON(t, licenseShowCmd, nil)
if err != nil {
t.Fatal(err)
}
want, err := os.ReadFile(filepath.Join(pkg, "testdata/json/license-show-no-key-legacy.golden.json"))

if err != nil {
t.Fatal(err)
}
if out != string(want) {
t.Fatalf("got %q, want %q", out, want)
}
}
3 changes: 2 additions & 1 deletion cmd/commands/plugin_debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os/exec"
"path/filepath"

"github.com/nself-org/cli/internal/compat"
"github.com/nself-org/cli/internal/plugin"
"github.com/nself-org/cli/internal/ui"

Expand Down Expand Up @@ -102,7 +103,7 @@ func runPluginDebug(cmd *cobra.Command, args []string) error {
dlvCmd.Dir = pluginPath
dlvCmd.Stdout = os.Stdout
dlvCmd.Stderr = os.Stderr
dlvCmd.Env = append(os.Environ(), "NSELF_PLUGIN_DEV=1")
dlvCmd.Env = append(plugin.ExecEnv(os.Environ(), compat.V15), "NSELF_PLUGIN_DEV=1")

if err := dlvCmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
Expand Down
4 changes: 3 additions & 1 deletion cmd/commands/plugin_dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"regexp"
"strings"

"github.com/nself-org/cli/internal/compat"
"github.com/nself-org/cli/internal/plugin"
"github.com/nself-org/cli/internal/ui"

Expand Down Expand Up @@ -74,6 +75,7 @@ func runPluginDev(cmd *cobra.Command, args []string) error {
}
uiInfof("Launching debugger via %s plugin debug %s", nself, name)
debugCmd := exec.Command(nself, debugArgs...)
debugCmd.Env = plugin.ExecEnv(os.Environ(), compat.V15)
debugCmd.Stdout = os.Stdout
debugCmd.Stderr = os.Stderr
return debugCmd.Run()
Expand Down Expand Up @@ -111,7 +113,7 @@ func runPluginDev(cmd *cobra.Command, args []string) error {
devCmd.Dir = pluginPath
devCmd.Stdout = os.Stdout
devCmd.Stderr = os.Stderr
devCmd.Env = append(os.Environ(), "NSELF_PLUGIN_DEV=1")
devCmd.Env = append(plugin.ExecEnv(os.Environ(), compat.V15), "NSELF_PLUGIN_DEV=1")

if err := devCmd.Run(); err != nil {
// Non-zero exit is expected when user Ctrl+Cs (signal).
Expand Down
8 changes: 0 additions & 8 deletions cmd/commands/plugin_envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import (
"strings"

"github.com/nself-org/cli/internal/errs"
"github.com/nself-org/cli/internal/output"
"github.com/nself-org/cli/internal/plugin"
"github.com/nself-org/cli/internal/version"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -281,13 +280,6 @@ func pluginHubBody(run func(*cobra.Command, []string) error) func(*cobra.Command
}
}

// legacyBare reports whether NSELF_JSON_LEGACY asks for the pre-contract bare
// form: an empty list that printed `null` then must still print `null`.
func legacyBare() bool {
v := os.Getenv(output.LegacyEnvVar)
return v == "1" || strings.EqualFold(v, "true")
}

func init() {
pluginCmd.RunE = pluginHubBody(pluginCmd.RunE)
for _, c := range []*cobra.Command{pluginUpdateCmd, pluginRefreshCmd, pluginStartCmd, pluginStopCmd, pluginDisableCmd, pluginEnableCmd} {
Expand Down
Loading