Skip to content
Merged
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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,17 @@ TUI when stdin/stdout are terminals and prints help when piped. Later:

| Command | What it does |
| --- | --- |
| `zn vault list` | Every saved vault and server, the default starred |
| `zn vault list` | Every saved vault and server, the default starred. Entries marked `app` come from the desktop app and are managed there |
| `zn use <name>` | Set the terminal default (`zn use app` follows desktop again); desktop-managed scripts keep their own app default |
| `zn disconnect <name>` | Forget a server and its token |
| `zn vault remove <name\|url>` | Forget one of zn's own vaults or servers (a server's token too); note files stay |
| `zn vault mode root\|inbox` | Move a vault between the flat layout and the classic `inbox/` layout |

The list lives in `~/.config/zennotes/workspaces.toml`; server tokens go to
`credentials.toml` next to it, readable only by you. Scripts and CI can skip
the store with `--server <url>` and `ZENNOTES_REMOTE_TOKEN`.
the store with `--server <url>` and `ZENNOTES_REMOTE_TOKEN`. If either file
stops parsing, zn says so (`zn doctor`, `zn status`) and refuses to write over
it rather than replace it with an empty list.

## Commands

Expand Down
12 changes: 11 additions & 1 deletion internal/cli/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ type Args struct {

var shortFlagRe = regexp.MustCompile(`^-[A-Za-z][\w-]*$`)

// longFlagRe is `--` followed by a letter. `---` is not a flag, so a body
// that starts with a frontmatter fence can follow --body.
var longFlagRe = regexp.MustCompile(`^--[A-Za-z]`)

// startsAFlag says whether the token after a value flag is the next flag
// (or the `--` terminator) rather than the value.
func startsAFlag(token string) bool {
return token == "--" || token == "-h" || longFlagRe.MatchString(token)
}

// valuelessFlags are the long flags that are switches, never `--flag <value>`.
// Without this list a switch written before a positional swallowed it:
// `zn open --new-window ~/notes` parsed as new-window="~/notes" with no path,
Expand Down Expand Up @@ -68,7 +78,7 @@ func Parse(argv []string) Args {
continue
}
name := token[2:]
if !valuelessFlags[name] && i+1 < len(argv) && !strings.HasPrefix(argv[i+1], "--") {
if !valuelessFlags[name] && i+1 < len(argv) && !startsAFlag(argv[i+1]) {
args.push(name, argv[i+1])
i++
} else {
Expand Down
56 changes: 56 additions & 0 deletions internal/cli/args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cli

import (
"reflect"
"strconv"
"strings"
"testing"
)

Expand Down Expand Up @@ -64,3 +66,57 @@ func TestOpenLaunchArgsAndMessage(t *testing.T) {
t.Fatalf("two, -n: %q", got)
}
}

// A body that opens with a frontmatter fence starts with `---`, which is
// not a flag; it must be taken as the value, while a real flag or the `--`
// terminator after a value flag still means the value is missing.
func TestValueFlagsAcceptValuesThatStartWithDashes(t *testing.T) {
fm := "---\ntitle: x\n---\nbody"
name, args, err := parseCommand([]string{"write", "a.md", "--body", fm})
if err != nil || name != "write" || args.Str("body") != fm {
t.Fatalf("frontmatter body: %q %v %v", name, args.Flags, err)
}
if _, args, err := parseCommand([]string{"capture", "--title", "---", "text"}); err != nil || args.Str("title") != "---" {
t.Fatalf("bare --- as a value: %v %v", args.Flags, err)
}
if _, args, err := parseCommand([]string{"write", "a.md", "--body", "-x"}); err != nil || args.Str("body") != "-x" {
t.Fatalf("single dash value: %v %v", args.Flags, err)
}
for _, argv := range [][]string{
{"create", "--title", "--json"},
{"create", "--title", "--", "x"},
{"create", "--title", "-h"},
{"create", "--title"},
} {
if _, _, err := parseCommand(argv); err == nil || !strings.Contains(err.Error(), "needs a value") {
t.Errorf("%v: want a missing-value error, got %v", argv, err)
}
}
if got := Parse([]string{"--body", fm}); got.Str("body") != fm {
t.Fatalf("Parse: %v", got.Flags)
}
}

func TestSuggestionsCountSwapsAsOneEdit(t *testing.T) {
roots := commandRoots()
for input, want := range map[string]string{"lsit": "list", "serach": "search", "craete": "create", "sevrer": "server", "lits": "list", "statsu": "status"} {
if got := suggestion(input, roots); got != " Did you mean "+strconv.Quote(want)+"?" {
t.Errorf("suggestion(%q) = %q, want %q", input, got, want)
}
}
if got := suggestion("zzzzzz", roots); got != "" {
t.Errorf("far-off input must not get a suggestion: %q", got)
}
}

// `--tag` is the flag form of tag find's positional; it must satisfy the
// positional requirement the way --path does for note commands.
func TestTagFindAcceptsTheTagFlag(t *testing.T) {
name, args, err := parseCommand([]string{"tag", "find", "--tag", "work", "--json"})
if err != nil || name != "tag find" || args.Str("tag") != "work" {
t.Fatalf("tag find --tag: %q %v %v", name, args.Flags, err)
}
if _, _, err := parseCommand([]string{"tag", "find"}); err == nil {
t.Fatal("tag find without a tag must be a usage error")
}
}
17 changes: 13 additions & 4 deletions internal/cli/commands_diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type workspaceStatus struct {
Location string `json:"location,omitempty"`
AuthConfigured bool `json:"authConfigured"`
Problem string `json:"problem,omitempty"`
Warning string `json:"warning,omitempty"`
InstallationOwner string `json:"installationOwner,omitempty"`
}

Expand All @@ -41,6 +42,9 @@ func currentStatus(args Args) (workspaceStatus, error) {
if install, err := selfupdate.Current(); err == nil {
st.InstallationOwner = install.Owner
}
if _, err := config.LoadWorkspacesFile(); err != nil {
st.Warning = "saved vaults and servers are unavailable: " + err.Error()
}
switch {
case args.Str("server") != "":
st.SelectedBy = "--server"
Expand Down Expand Up @@ -91,6 +95,9 @@ func cmdStatus(args Args) error {
emitLine("Installation owner: " + st.InstallationOwner)
emitLine("Config: " + st.ConfigPath)
emitLine("Workspace source: " + st.Source + " · selected by " + st.SelectedBy)
if st.Warning != "" {
emitLine("Warning: " + st.Warning)
}
if st.Problem != "" {
emitLine(st.Problem)
} else {
Expand Down Expand Up @@ -118,6 +125,8 @@ func cmdDoctor(ctx context.Context, args Args) error {
checks = append(checks, diagnosticCheck{Name: name, OK: err == nil, Message: message})
}
add("configuration", config.ConfigTomlPath(), config.ValidateConfig())
ws, wsErr := config.LoadWorkspacesFile()
add("saved vaults", fmt.Sprintf("%d vaults and %d servers in %s", len(ws.Vaults), len(ws.Servers), config.WorkspacesPath()), wsErr)
if installation, err := selfupdate.Current(); err == nil {
add("installation", installation.Owner+": "+installation.Instruction, nil)
}
Expand All @@ -131,11 +140,11 @@ func cmdDoctor(ctx context.Context, args Args) error {
add("editor", "VISUAL/EDITOR unset; config edit uses the platform fallback", nil)
}
if info, err := os.Stat(config.CredentialsPath()); err == nil {
var permissionErr error
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
permissionErr = fmt.Errorf("credentials.toml must be private (mode 0600)")
problem := config.CredentialsProblem()
if problem == nil && runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
problem = fmt.Errorf("credentials.toml must be private (mode 0600)")
}
add("credentials", "Stored credentials are private", permissionErr)
add("credentials", "Stored credentials are private", problem)
}
target, err := ResolveTargetFromArgs(args)
add("workspace", "Workspace selected", err)
Expand Down
56 changes: 47 additions & 9 deletions internal/cli/commands_vault_capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,24 +65,50 @@ func cmdVaultInfo(ctx context.Context, b backend.Backend, args Args) error {
}

// vaultListEntry keeps `root` for local entries so scripts reading the JSON
// keep working now that servers appear in the same list.
// keep working now that servers appear in the same list. Source says who
// saved the entry: "terminal" for zn's own list (what `zn use` and `zn vault
// remove` act on), "app" for the ZenNotes desktop app's, which zn only reads.
type vaultListEntry struct {
Name string `json:"name"`
Kind string `json:"kind"`
Source string `json:"source"`
Root string `json:"root,omitempty"`
BaseURL string `json:"baseUrl,omitempty"`
LastOpenedAt *int64 `json:"lastOpenedAt"`
IsDefault bool `json:"isDefault"`
}

const (
sourceTerminal = "terminal"
sourceApp = "app"
)

// desktopWorkspaces is the desktop app's vault and server list in the shape
// of zn's own, so the same name/path/URL/host matching applies to both.
func desktopWorkspaces() config.Workspaces {
ws := config.Workspaces{}
for _, v := range config.KnownVaults() {
ws.Vaults = append(ws.Vaults, config.LocalWorkspace{Name: v.Name, Root: v.Root})
}
for _, p := range config.RemoteProfiles() {
ws.Servers = append(ws.Servers, config.ServerWorkspace{Name: p.Name, URL: p.BaseURL})
}
return ws
}

func cmdVaultList(args Args) error {
source, err := backend.ResolveWorkspaceSource(args.Str("workspace-source"))
if err != nil {
return err
}
// Every listed name must resolve through `--vault <name>` in this
// source, and a desktop-managed zn never resolves zn's own names.
ws := config.Workspaces{}
if source == "terminal" {
ws = config.LoadWorkspaces()
var loadErr error
if ws, loadErr = config.LoadWorkspacesFile(); loadErr != nil {
emitError(fmt.Sprintf("%v; zn's own vaults and servers are not shown. `zn doctor` has details.", loadErr))
}
}
defaultTarget, err := ResolveTargetFromArgs(args)
if err != nil && !errors.Is(err, config.ErrNoVault) {
Expand All @@ -99,23 +125,23 @@ func cmdVaultList(args Args) error {
seenURL := map[string]bool{}
for _, v := range ws.Vaults {
seenRoot[filepath.Clean(v.Root)] = true
entries = append(entries, vaultListEntry{Name: v.Name, Kind: "local", Root: v.Root, IsDefault: isDefault("local", v.Root, "")})
entries = append(entries, vaultListEntry{Name: v.Name, Kind: "local", Source: sourceTerminal, Root: v.Root, IsDefault: isDefault("local", v.Root, "")})
}
for _, s := range ws.Servers {
seenURL[strings.ToLower(s.URL)] = true
entries = append(entries, vaultListEntry{Name: s.Name, Kind: "remote", BaseURL: s.URL, IsDefault: isDefault("remote", "", s.URL)})
seenURL[strings.ToLower(strings.TrimRight(s.URL, "/"))] = true
entries = append(entries, vaultListEntry{Name: s.Name, Kind: "remote", Source: sourceTerminal, BaseURL: s.URL, IsDefault: isDefault("remote", "", s.URL)})
}
for _, v := range config.KnownVaults() {
if seenRoot[filepath.Clean(v.Root)] {
continue
}
entries = append(entries, vaultListEntry{Name: v.Name, Kind: "local", Root: v.Root, LastOpenedAt: v.LastOpenedAt, IsDefault: isDefault("local", v.Root, "")})
entries = append(entries, vaultListEntry{Name: v.Name, Kind: "local", Source: sourceApp, Root: v.Root, LastOpenedAt: v.LastOpenedAt, IsDefault: isDefault("local", v.Root, "")})
}
for _, p := range config.RemoteProfiles() {
if seenURL[strings.ToLower(p.BaseURL)] {
if seenURL[strings.ToLower(strings.TrimRight(p.BaseURL, "/"))] {
continue
}
entries = append(entries, vaultListEntry{Name: p.Name, Kind: "remote", BaseURL: p.BaseURL, LastOpenedAt: p.LastConnectedAt, IsDefault: isDefault("remote", "", p.BaseURL)})
entries = append(entries, vaultListEntry{Name: p.Name, Kind: "remote", Source: sourceApp, BaseURL: p.BaseURL, LastOpenedAt: p.LastConnectedAt, IsDefault: isDefault("remote", "", p.BaseURL)})
}
if args.Bool("json") {
emitJSON(entries)
Expand All @@ -127,11 +153,15 @@ func cmdVaultList(args Args) error {
}
nameWidth := 4
kindWidth := 0
hasApp := false
for _, e := range entries {
nameWidth = max(nameWidth, len([]rune(e.Name)))
if e.Kind == "remote" {
kindWidth = 6
}
if e.Source == sourceApp {
hasApp = true
}
}
for _, e := range entries {
marker := " "
Expand All @@ -146,11 +176,19 @@ func cmdVaultList(args Args) error {
if kindWidth > 0 {
kind = pad(e.Kind, kindWidth) + " "
}
source := ""
if hasApp {
source = pad(e.Source, len(sourceTerminal)) + " "
}
location := e.Root
if location == "" {
location = e.BaseURL
}
emitLine(fmt.Sprintf("%s %s %s%s %s", marker, pad(e.Name, nameWidth), kind, pad(age, 8), location))
emitLine(fmt.Sprintf("%s %s %s%s%s %s", marker, pad(e.Name, nameWidth), kind, source, pad(age, 8), location))
}
if hasApp {
emitLine("")
emitLine("Entries marked app are saved by the ZenNotes desktop app and managed there. `zn use`, `zn disconnect` and `zn vault remove` act on zn's own (terminal) entries.")
}
return nil
}
Expand Down
Loading
Loading