a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
553 lines
17 KiB
Go
553 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/jedib0t/go-pretty/v6/table"
|
|
"github.com/jedib0t/go-pretty/v6/text"
|
|
"github.com/spf13/cobra"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/pathkey"
|
|
"github.com/zzet/gortex/internal/progress"
|
|
"github.com/zzet/gortex/internal/tui"
|
|
)
|
|
|
|
var workspaceCmd = &cobra.Command{
|
|
Use: "workspace",
|
|
Short: "Inspect and edit per-repo workspace declarations",
|
|
Long: `Manage the per-repo .gortex.yaml workspace and project slugs.
|
|
|
|
The hard graph boundary keys every node and contract on its
|
|
workspace. Two repos that should pair their
|
|
contracts as one logical service must declare the same 'workspace:'
|
|
in their respective .gortex.yaml files. Without that declaration,
|
|
each repo defaults to its own workspace (named after the repo) and
|
|
cross-repo contract matching stops at the boundary.
|
|
|
|
This command is the migration path: it lists what each tracked repo
|
|
currently declares and writes new declarations atomically without
|
|
disturbing other keys in the file.`,
|
|
}
|
|
|
|
var workspaceListJSON bool
|
|
|
|
var workspaceListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "Show the workspace and project declared in each tracked repo's .gortex.yaml",
|
|
RunE: runWorkspaceList,
|
|
}
|
|
|
|
var workspaceSetCmd = &cobra.Command{
|
|
Use: "set <repo> <workspace> [project]",
|
|
Short: "Write workspace (and optional project) for a tracked repo",
|
|
Long: `Updates one repo's workspace declaration.
|
|
|
|
The repo argument is matched against the global config: it can be
|
|
a name (e.g. 'tuck-api'), an absolute path, or a path relative to
|
|
the cwd. Project defaults to the workspace slug when omitted.
|
|
|
|
Without --global the value is written to the repo's .gortex.yaml.
|
|
With --global the value is written to
|
|
~/.gortex/config.yaml (your user-level config), which is
|
|
the right choice for OSS / read-only repos where you don't want
|
|
to leave any artifact in the repo. Global overrides win over
|
|
.gortex.yaml at resolution time, so you can also use --global to
|
|
override a workspace the repo author chose.`,
|
|
Args: cobra.RangeArgs(2, 3),
|
|
RunE: runWorkspaceSet,
|
|
}
|
|
|
|
var (
|
|
workspaceSetAll bool
|
|
workspaceSetRoot string
|
|
workspaceSetGlobal bool
|
|
)
|
|
|
|
var workspaceSetAllCmd = &cobra.Command{
|
|
Use: "set-all <workspace>",
|
|
Short: "Stamp the same workspace into every tracked repo (interactive confirmation)",
|
|
Long: `Bulk migration helper. Walks every tracked repo in the global config
|
|
and writes the same 'workspace:' value to each.
|
|
|
|
By default the command prints the planned changes and asks for
|
|
confirmation. Pass --yes to skip the prompt (CI / scripted use).
|
|
--root <path> restricts the bulk update to repos under that prefix
|
|
(e.g. only your "work" repos). --global writes to
|
|
~/.gortex/config.yaml instead of touching each repo's
|
|
.gortex.yaml — the OSS-friendly path.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runWorkspaceSetAll,
|
|
}
|
|
|
|
func init() {
|
|
workspaceCmd.AddCommand(workspaceListCmd)
|
|
workspaceCmd.AddCommand(workspaceSetCmd)
|
|
workspaceCmd.AddCommand(workspaceSetAllCmd)
|
|
workspaceListCmd.Flags().BoolVar(&workspaceListJSON, "json", false, "emit machine-readable JSON instead of a table")
|
|
workspaceSetCmd.Flags().BoolVar(&workspaceSetGlobal, "global", false, "write to ~/.gortex/config.yaml instead of the repo's .gortex.yaml (OSS-friendly)")
|
|
workspaceSetAllCmd.Flags().BoolVarP(&workspaceSetAll, "yes", "y", false, "skip interactive confirmation")
|
|
workspaceSetAllCmd.Flags().StringVar(&workspaceSetRoot, "root", "", "only stamp repos whose path starts with this prefix")
|
|
workspaceSetAllCmd.Flags().BoolVar(&workspaceSetGlobal, "global", false, "write to ~/.gortex/config.yaml instead of each repo's .gortex.yaml")
|
|
rootCmd.AddCommand(workspaceCmd)
|
|
}
|
|
|
|
// loadGlobalRepos reads the global config (~/.gortex/config.yaml
|
|
// by default, or whatever --config points at) and returns the tracked
|
|
// repo entries. Failure to read the config returns an error rather
|
|
// than an empty list — silently doing nothing on a typo'd config
|
|
// path is a worse default than failing loudly.
|
|
func loadGlobalRepos() ([]config.RepoEntry, error) {
|
|
path := config.DefaultGlobalConfigPath()
|
|
if cfgFile != "" {
|
|
path = cfgFile
|
|
}
|
|
gc, err := config.LoadGlobal(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load global config: %w", err)
|
|
}
|
|
return gc.Repos, nil
|
|
}
|
|
|
|
// readRepoYAML loads <repoPath>/.gortex.yaml and returns the parsed
|
|
// generic map. Missing file is not an error — returns an empty map
|
|
// so callers can write a fresh file.
|
|
func readRepoYAML(repoPath string) (map[string]any, error) {
|
|
p := filepath.Join(repoPath, ".gortex.yaml")
|
|
data, err := os.ReadFile(p)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return map[string]any{}, nil
|
|
}
|
|
return nil, fmt.Errorf("read %s: %w", p, err)
|
|
}
|
|
out := make(map[string]any)
|
|
if err := yaml.Unmarshal(data, &out); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", p, err)
|
|
}
|
|
if out == nil {
|
|
out = make(map[string]any)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// writeRepoYAML serialises the map back to <repoPath>/.gortex.yaml.
|
|
// Uses an atomic tmp+rename so a partial write doesn't leave the
|
|
// file truncated. The yaml.v3 encoder preserves key order from the
|
|
// input map's iteration which is non-deterministic; we sort keys to
|
|
// keep diffs readable.
|
|
func writeRepoYAML(repoPath string, payload map[string]any) error {
|
|
p := filepath.Join(repoPath, ".gortex.yaml")
|
|
keys := make([]string, 0, len(payload))
|
|
for k := range payload {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
// Hand-roll the encoder so we can write keys in deterministic
|
|
// order. yaml.v3 doesn't expose a sorted-encode helper.
|
|
var buf strings.Builder
|
|
for _, k := range keys {
|
|
v, err := yaml.Marshal(map[string]any{k: payload[k]})
|
|
if err != nil {
|
|
return fmt.Errorf("marshal %s: %w", k, err)
|
|
}
|
|
buf.Write(v)
|
|
}
|
|
|
|
tmp := p + ".tmp"
|
|
if err := os.WriteFile(tmp, []byte(buf.String()), 0o644); err != nil {
|
|
return fmt.Errorf("write %s: %w", tmp, err)
|
|
}
|
|
if err := os.Rename(tmp, p); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("rename %s -> %s: %w", tmp, p, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// matchRepo finds the RepoEntry whose name OR absolute path matches
|
|
// `target`. Returns (-1, error) when no entry matches.
|
|
func matchRepo(repos []config.RepoEntry, target string) (int, error) {
|
|
abs, _ := filepath.Abs(target)
|
|
for i, r := range repos {
|
|
if r.Name == target {
|
|
return i, nil
|
|
}
|
|
ra, _ := filepath.Abs(r.Path)
|
|
if pathkey.EqualPaths(ra, abs) {
|
|
return i, nil
|
|
}
|
|
// Allow short suffix matches like "tuck-api" against
|
|
// "/Users/x/code/work/tuck-api".
|
|
if strings.HasSuffix(r.Path, string(filepath.Separator)+target) {
|
|
return i, nil
|
|
}
|
|
}
|
|
return -1, fmt.Errorf("no tracked repo matches %q (try `gortex daemon status` for the list)", target)
|
|
}
|
|
|
|
// workspaceListEntry is one repo's resolved workspace/project view —
|
|
// the row of `gortex workspace list`. It is also the JSON shape emitted
|
|
// under --json; the table renderer projects the same struct.
|
|
type workspaceListEntry struct {
|
|
// Repo is the repo's configured name, falling back to the path
|
|
// basename when the global config declares no explicit name.
|
|
Repo string `json:"repo"`
|
|
// Path is the absolute on-disk path of the repository.
|
|
Path string `json:"path"`
|
|
// Workspace is the effective workspace slug after applying the
|
|
// precedence rules (global config > .gortex.yaml > default).
|
|
Workspace string `json:"workspace"`
|
|
// Project is the effective project slug, same precedence.
|
|
Project string `json:"project"`
|
|
// Source records where the effective Workspace came from:
|
|
// "global", ".gortex.yaml", "default", or a "global (overrides
|
|
// .gortex.yaml=...)" note when an override is shadowing a repo
|
|
// declaration.
|
|
Source string `json:"source"`
|
|
// Error carries a non-empty message when the repo's .gortex.yaml
|
|
// could not be read or parsed; the other fields are unset then.
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// collectWorkspaceList resolves every tracked repo into a
|
|
// workspaceListEntry. The precedence rules (global RepoEntry override >
|
|
// .gortex.yaml declaration > default repo prefix) are applied here so
|
|
// the table and JSON renderers stay byte-identical in semantics.
|
|
func collectWorkspaceList(repos []config.RepoEntry) []workspaceListEntry {
|
|
entries := make([]workspaceListEntry, 0, len(repos))
|
|
for _, r := range repos {
|
|
// Precedence: global config (RepoEntry) > .gortex.yaml > default.
|
|
ws, proj, src := "", "", ""
|
|
if r.Workspace != "" || r.Project != "" {
|
|
ws, proj, src = r.Workspace, r.Project, "global"
|
|
}
|
|
yamlWS, yamlProj := "", ""
|
|
payload, yamlErr := readRepoYAML(r.Path)
|
|
if yamlErr != nil {
|
|
entries = append(entries, workspaceListEntry{
|
|
Repo: repoLabel(r),
|
|
Path: r.Path,
|
|
Error: yamlErr.Error(),
|
|
})
|
|
continue
|
|
}
|
|
yamlWS, _ = payload["workspace"].(string)
|
|
yamlProj, _ = payload["project"].(string)
|
|
if ws == "" {
|
|
ws = yamlWS
|
|
if ws != "" {
|
|
src = ".gortex.yaml"
|
|
}
|
|
}
|
|
if proj == "" {
|
|
proj = yamlProj
|
|
}
|
|
// Note when global override is shadowing a repo declaration.
|
|
if r.Workspace != "" && yamlWS != "" && r.Workspace != yamlWS {
|
|
src = "global (overrides .gortex.yaml=" + yamlWS + ")"
|
|
}
|
|
if ws == "" {
|
|
ws = "(default: " + repoLabel(r) + ")"
|
|
if src == "" {
|
|
src = "default"
|
|
}
|
|
}
|
|
if proj == "" {
|
|
proj = "(default: " + repoLabel(r) + ")"
|
|
}
|
|
entries = append(entries, workspaceListEntry{
|
|
Repo: repoLabel(r),
|
|
Path: r.Path,
|
|
Workspace: ws,
|
|
Project: proj,
|
|
Source: src,
|
|
})
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func runWorkspaceList(cmd *cobra.Command, _ []string) error {
|
|
repos, err := loadGlobalRepos()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
entries := collectWorkspaceList(repos)
|
|
|
|
if workspaceListJSON {
|
|
enc := json.NewEncoder(cmd.OutOrStdout())
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(entries)
|
|
}
|
|
|
|
tty := progress.IsTTY(cmd.ErrOrStderr()) && !noProgress
|
|
|
|
if len(entries) == 0 {
|
|
if tty {
|
|
emitWorkspaceBanner(cmd.ErrOrStderr(), "list", "Workspace declarations across every tracked repo.")
|
|
fmt.Fprintln(cmd.ErrOrStderr(), " "+progress.StyleHint.Render("◌ no tracked repos — run `gortex track <path>` to add one"))
|
|
fmt.Fprintln(cmd.ErrOrStderr())
|
|
} else {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "(no tracked repos)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if tty {
|
|
emitWorkspaceBanner(cmd.ErrOrStderr(), "list", "Workspace declarations across every tracked repo.")
|
|
}
|
|
|
|
t := table.NewWriter()
|
|
t.SetOutputMirror(cmd.OutOrStdout())
|
|
t.SetStyle(table.StyleLight)
|
|
t.AppendHeader(table.Row{"repo", "workspace", "project", "source", "path"})
|
|
t.SetColumnConfigs([]table.ColumnConfig{
|
|
{Number: 1, Align: text.AlignLeft},
|
|
{Number: 2, Align: text.AlignLeft},
|
|
{Number: 3, Align: text.AlignLeft},
|
|
{Number: 4, Align: text.AlignLeft},
|
|
{Number: 5, Align: text.AlignLeft},
|
|
})
|
|
|
|
for _, e := range entries {
|
|
if e.Error != "" {
|
|
t.AppendRow(table.Row{e.Repo, "(error)", e.Error, "", e.Path})
|
|
continue
|
|
}
|
|
t.AppendRow(table.Row{e.Repo, e.Workspace, e.Project, e.Source, e.Path})
|
|
}
|
|
t.Render()
|
|
return nil
|
|
}
|
|
|
|
func repoLabel(r config.RepoEntry) string {
|
|
if r.Name != "" {
|
|
return r.Name
|
|
}
|
|
return filepath.Base(r.Path)
|
|
}
|
|
|
|
func runWorkspaceSet(cmd *cobra.Command, args []string) error {
|
|
repos, err := loadGlobalRepos()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idx, err := matchRepo(repos, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
workspace := args[1]
|
|
project := workspace
|
|
if len(args) >= 3 {
|
|
project = args[2]
|
|
}
|
|
r := repos[idx]
|
|
|
|
if workspaceSetGlobal {
|
|
path, err := stampWorkspaceGlobal(r, workspace, project)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
emitWorkspaceSetSummary(cmd, path, repoLabel(r), workspace, project, true)
|
|
return nil
|
|
}
|
|
|
|
if err := stampWorkspace(r.Path, workspace, project); err != nil {
|
|
return err
|
|
}
|
|
emitWorkspaceSetSummary(cmd, r.Path+"/.gortex.yaml", repoLabel(r), workspace, project, false)
|
|
return nil
|
|
}
|
|
|
|
// emitWorkspaceBanner prints the workspace-subcommand banner on stderr. We
|
|
// keep banners on stderr so the table / JSON / one-liner on stdout stays
|
|
// pipe-clean for scripts.
|
|
func emitWorkspaceBanner(w io.Writer, mode, subtitle string) {
|
|
if !progress.IsTTY(w) || noProgress {
|
|
return
|
|
}
|
|
banner := tui.Banner{
|
|
Title: "gortex workspace — " + mode,
|
|
Subtitle: subtitle,
|
|
}.Render()
|
|
fmt.Fprintln(w)
|
|
fmt.Fprintln(w, banner)
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// emitWorkspaceSetSummary prints the post-set summary card showing the file
|
|
// modified, the repo affected, and the new workspace/project. Non-TTY callers
|
|
// see the legacy one-liner so script parsers keep matching on "updated …".
|
|
func emitWorkspaceSetSummary(cmd *cobra.Command, target, repo, workspace, project string, global bool) {
|
|
w := cmd.ErrOrStderr()
|
|
if !progress.IsTTY(w) || noProgress {
|
|
out := cmd.OutOrStdout()
|
|
if global {
|
|
fmt.Fprintf(out, "updated %s: %s → workspace=%s project=%s\n",
|
|
target, repo, workspace, project)
|
|
} else {
|
|
fmt.Fprintf(out, "updated %s: workspace=%s project=%s\n",
|
|
target, workspace, project)
|
|
}
|
|
fmt.Fprintln(out, "\nNote: a running daemon needs `gortex daemon reload` (or restart) to pick up the change.")
|
|
return
|
|
}
|
|
|
|
mode := "set"
|
|
if global {
|
|
mode = "set --global"
|
|
}
|
|
emitWorkspaceBanner(w, mode, "Updated workspace declaration for "+repo+".")
|
|
|
|
fmt.Fprintln(w, " "+progress.StyleOK.Render("✓")+" "+progress.StyleStrong.Render(repo))
|
|
stats := []string{
|
|
progress.Stat(workspace, "workspace", progress.StatGood),
|
|
}
|
|
if project != "" && project != workspace {
|
|
stats = append(stats, progress.Stat(project, "project", progress.StatNeutral))
|
|
}
|
|
fmt.Fprintln(w, " "+progress.StatStrip(stats...))
|
|
fmt.Fprintln(w, " "+progress.Row("file", target, 8))
|
|
fmt.Fprintln(w)
|
|
fmt.Fprintln(w, " "+progress.Caption("a running daemon needs `gortex daemon reload` (or restart) to pick up the change."))
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// stampWorkspace writes workspace+project into a repo's .gortex.yaml
|
|
// without disturbing other keys (guards, exclude rules, semantic
|
|
// providers, etc.). Idempotent — re-running with the same values
|
|
// is a no-op rewrite.
|
|
func stampWorkspace(repoPath, workspace, project string) error {
|
|
payload, err := readRepoYAML(repoPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload["workspace"] = workspace
|
|
payload["project"] = project
|
|
return writeRepoYAML(repoPath, payload)
|
|
}
|
|
|
|
// stampWorkspaceGlobal writes the workspace/project override onto
|
|
// the matching RepoEntry in `~/.gortex/config.yaml`. Returns
|
|
// the path of the file modified for the user-facing message. Used
|
|
// when the user passes --global — the OSS-friendly path that
|
|
// leaves no trace in the repo itself.
|
|
func stampWorkspaceGlobal(target config.RepoEntry, workspace, project string) (string, error) {
|
|
path := config.DefaultGlobalConfigPath()
|
|
if cfgFile != "" {
|
|
path = cfgFile
|
|
}
|
|
gc, err := config.LoadGlobal(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("load global config: %w", err)
|
|
}
|
|
updated := false
|
|
for i := range gc.Repos {
|
|
// Match on absolute path — the most stable identity. Name and
|
|
// prefix can collide (two repos can share a basename) but a
|
|
// path is unique to the on-disk location.
|
|
ra, _ := filepath.Abs(gc.Repos[i].Path)
|
|
ta, _ := filepath.Abs(target.Path)
|
|
if ra != ta {
|
|
continue
|
|
}
|
|
gc.Repos[i].Workspace = workspace
|
|
gc.Repos[i].Project = project
|
|
updated = true
|
|
break
|
|
}
|
|
if !updated {
|
|
return "", fmt.Errorf("repo %s not found in %s", target.Path, path)
|
|
}
|
|
if err := gc.Save(); err != nil {
|
|
return "", fmt.Errorf("save global config: %w", err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func runWorkspaceSetAll(cmd *cobra.Command, args []string) error {
|
|
workspace := args[0]
|
|
repos, err := loadGlobalRepos()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(repos) == 0 {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "(no tracked repos to update)")
|
|
return nil
|
|
}
|
|
|
|
// Filter to --root prefix when set. Match on absolute path
|
|
// prefix so 'gortex workspace set-all work --root ~/code/work'
|
|
// only stamps repos under the work tree.
|
|
var rootAbs string
|
|
if workspaceSetRoot != "" {
|
|
rootAbs, err = filepath.Abs(workspaceSetRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve --root: %w", err)
|
|
}
|
|
}
|
|
planned := make([]config.RepoEntry, 0, len(repos))
|
|
for _, r := range repos {
|
|
if rootAbs != "" {
|
|
ra, _ := filepath.Abs(r.Path)
|
|
if !strings.HasPrefix(ra, rootAbs) {
|
|
continue
|
|
}
|
|
}
|
|
planned = append(planned, r)
|
|
}
|
|
if len(planned) == 0 {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "(no tracked repos match --root)")
|
|
return nil
|
|
}
|
|
|
|
fmt.Fprintf(cmd.OutOrStdout(), "Plan: stamp workspace=%q (project=workspace) into %d repos:\n", workspace, len(planned))
|
|
for _, r := range planned {
|
|
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", r.Path)
|
|
}
|
|
if !workspaceSetAll {
|
|
fmt.Fprint(cmd.OutOrStdout(), "\nProceed? [y/N] ")
|
|
var answer string
|
|
_, _ = fmt.Fscanln(os.Stdin, &answer)
|
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
|
if answer != "y" && answer != "yes" {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "aborted")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
updated := 0
|
|
var failures []string
|
|
for _, r := range planned {
|
|
var stampErr error
|
|
if workspaceSetGlobal {
|
|
_, stampErr = stampWorkspaceGlobal(r, workspace, workspace)
|
|
} else {
|
|
stampErr = stampWorkspace(r.Path, workspace, workspace)
|
|
}
|
|
if stampErr != nil {
|
|
failures = append(failures, fmt.Sprintf("%s: %v", r.Path, stampErr))
|
|
continue
|
|
}
|
|
updated++
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "\nupdated %d/%d repos\n", updated, len(planned))
|
|
if len(failures) > 0 {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "\nfailures:")
|
|
for _, f := range failures {
|
|
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", f)
|
|
}
|
|
}
|
|
fmt.Fprintln(cmd.OutOrStdout(), "\nNote: a running daemon needs `gortex daemon reload` (or restart) to pick up the change.")
|
|
return nil
|
|
}
|