feat: update agent commands to require existing config file and enhance documentation

Kim committed May 8, 2026 at 12:31 UTC c53b0ec1144f2efa8b0a019c95573bc8552a1360
5 files changed +40 -88
cmd/portal-tunnel/README.md
+1 -1
@@ -120,7 +120,7 @@ Runs Portal as a managed long-lived tunnel agent.
120 - `portal agent dashboard` opens the mouse-capable local TUI for tunnel add/delete, per-tunnel relay add/delete/listing, and multi-hop route changes.
121 - `portal agent stop` asks the local agent to shut down, then disables/stops the OS service so intentional shutdown is not immediately restarted.
122 - `portal agent restart` stops the running agent if present, installs or updates the OS service from the existing config, and starts it again.
123 -- If the config file is missing, `portal agent run` creates a default config and the agent creates the identity file on first tunnel start.
123 +- `portal agent run`, `stop`, and `restart` require an existing config file. `portal agent dashboard` can attach with only the default state directory or an explicit `--state-dir`.
124
125 Default paths:
126
cmd/portal-tunnel/agent.go
+34 -31
@@ -54,7 +54,7 @@ func runAgentRunCommand(args []string) error {
54 return err
55 }
56
57 - cfg, err := agent.LoadConfig(configPath)
57 + cfg, err := agent.LoadExistingConfig(configPath)
58 if err != nil {
59 return err
60 }
@@ -226,14 +226,26 @@ func runAgentStopCommand(args []string) error {
226 return err
227 }
228
229 - cfg, resolvedStateDir, err := loadAgentCommandConfig(configPath, stateDir)
230 - if err != nil {
231 - return err
229 + configPath = strings.TrimSpace(configPath)
230 + stateDir = strings.TrimSpace(stateDir)
231 + cfg := agent.Config{Agent: agent.AgentConfig{ServiceName: agent.DefaultServiceName}}
232 + if configPath != "" || stateDir == "" {
233 + if configPath == "" {
234 + configPath = service.DefaultConfigPath()
235 + }
236 + var err error
237 + cfg, err = agent.LoadExistingConfig(configPath)
238 + if err != nil {
239 + return err
240 + }
241 + }
242 + if stateDir != "" {
243 + cfg.Agent.StateDir = stateDir
244 }
245 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
246 defer cancel()
247
236 - shutdownErr := agent.Shutdown(ctx, resolvedStateDir)
248 + shutdownErr := agent.Shutdown(ctx, cfg.Agent.StateDir)
249 if err := service.StopDisable(ctx, cfg.Agent.ServiceName); err != nil {
250 if shutdownErr == nil {
251 fmt.Fprintf(os.Stderr, "Warning: agent stopped, but service manager cleanup failed: %v\n", err)
@@ -263,14 +275,25 @@ func runAgentDashboardCommand(args []string) error {
275 return err
276 }
277
266 - _, resolvedStateDir, err := loadAgentCommandConfig(configPath, stateDir)
267 - if err != nil {
268 - return err
269 - }
270 - if strings.TrimSpace(configPath) == "" {
278 + configPath = strings.TrimSpace(configPath)
279 + stateDir = strings.TrimSpace(stateDir)
280 + if configPath == "" {
281 configPath = service.DefaultConfigPath()
282 }
273 - return agent.RunDashboard(configPath, resolvedStateDir)
283 + if stateDir == "" {
284 + if _, err := os.Stat(configPath); err == nil {
285 + cfg, err := agent.LoadExistingConfig(configPath)
286 + if err != nil {
287 + return err
288 + }
289 + stateDir = cfg.Agent.StateDir
290 + } else if errors.Is(err, os.ErrNotExist) {
291 + stateDir = service.DefaultDataDir()
292 + } else {
293 + return err
294 + }
295 + }
296 + return agent.RunDashboard(configPath, stateDir)
297 }
298
299 func waitAgentStatus(ctx context.Context, stateDir string) (types.AgentStatusResponse, error) {
@@ -348,26 +371,6 @@ func agentCLIInteractive() bool {
371 return err == nil && stdout.Mode()&os.ModeCharDevice != 0
372 }
373
351 -func loadAgentCommandConfig(configPath, stateDir string) (agent.Config, string, error) {
352 - configPath = strings.TrimSpace(configPath)
353 - stateDir = strings.TrimSpace(stateDir)
354 - if stateDir != "" && configPath == "" {
355 - cfg := agent.Config{Agent: agent.AgentConfig{StateDir: stateDir, ServiceName: agent.DefaultServiceName}}
356 - return cfg, stateDir, nil
357 - }
358 - if configPath == "" {
359 - configPath = service.DefaultConfigPath()
360 - }
361 - cfg, err := agent.LoadExistingConfig(configPath)
362 - if err != nil {
363 - return agent.Config{}, "", err
364 - }
365 - if stateDir != "" {
366 - cfg.Agent.StateDir = stateDir
367 - }
368 - return cfg, cfg.Agent.StateDir, nil
369 -}
370 -
374 func printAgentUsage(w io.Writer) {
375 utils.WriteCommandUsage(w,
376 []string{
cmd/portal-tunnel/agent/config.go
+2 -52
@@ -65,18 +65,6 @@ type HTTPRouteConfig struct {
65 Upstream string `koanf:"upstream"`
66 }
67
68 -func LoadConfig(path string) (Config, error) {
69 - absPath, err := ensureConfigDocument(path)
70 - if err != nil {
71 - return Config{}, err
72 - }
73 - cfg, _, err := readConfigDocument(absPath)
74 - if err != nil {
75 - return Config{}, err
76 - }
77 - return cfg, nil
78 -}
79 -
68 func LoadExistingConfig(path string) (Config, error) {
69 path = strings.TrimSpace(path)
70 if path == "" {
@@ -88,7 +76,7 @@ func LoadExistingConfig(path string) (Config, error) {
76 }
77 if _, err := os.Stat(absPath); err != nil {
78 if errors.Is(err, os.ErrNotExist) {
91 - return Config{}, fmt.Errorf("agent config %q does not exist; run `portal agent run` to create it", absPath)
79 + return Config{}, fmt.Errorf("agent config %q does not exist", absPath)
80 }
81 return Config{}, err
82 }
@@ -99,50 +87,12 @@ func LoadExistingConfig(path string) (Config, error) {
87 return cfg, nil
88 }
89
102 -func ensureConfigDocument(path string) (string, error) {
90 +func loadConfigDocument(path string) (Config, string, os.FileMode, error) {
91 path = strings.TrimSpace(path)
92 if path == "" {
93 path = service.DefaultConfigPath()
94 }
95 absPath, err := filepath.Abs(path)
108 - if err != nil {
109 - return "", err
110 - }
111 - configDir := filepath.Dir(absPath)
112 - if err := os.MkdirAll(configDir, 0o755); err != nil {
113 - return "", fmt.Errorf("create agent config directory %q: %w", configDir, err)
114 - }
115 - if _, err := os.Stat(absPath); err != nil {
116 - if errors.Is(err, os.ErrNotExist) {
117 - if err := writeConfigDocument(absPath, 0o644, defaultConfig()); err != nil {
118 - return "", fmt.Errorf("create default agent config %q: %w", absPath, err)
119 - }
120 - } else {
121 - return "", err
122 - }
123 - }
124 - return absPath, nil
125 -}
126 -
127 -func defaultConfig() Config {
128 - discovery := true
129 - return Config{
130 - Agent: AgentConfig{
131 - StateDir: service.DefaultDataDir(),
132 - ControlAddr: DefaultControlAddr,
133 - ServiceName: DefaultServiceName,
134 - },
135 - Tunnels: []TunnelConfig{{
136 - ID: "default",
137 - Name: "default",
138 - TargetAddr: defaultTargetAddr,
139 - Discovery: &discovery,
140 - }},
141 - }
142 -}
143 -
144 -func loadConfigDocument(path string) (Config, string, os.FileMode, error) {
145 - absPath, err := ensureConfigDocument(path)
96 if err != nil {
97 return Config{}, "", 0, err
98 }
docs/src/routes/cli-reference/+page.md
+2 -2
@@ -161,8 +161,8 @@ portal agent stop
161 portal agent restart
162 ```
163
164 -`portal agent run` reads or creates the platform default `config.toml`, installs or updates the OS-managed service, starts it in the background, and exits after the agent is ready. Use `--foreground` for local debugging without service registration.
165 -Use `portal agent dashboard` to attach to an already running managed agent. With `--foreground` in an interactive terminal, the dashboard attaches in the same process.
164 +`portal agent run` reads the platform default `config.toml`, installs or updates the OS-managed service, starts it in the background, and exits after the agent is ready. Use `--foreground` for local debugging without service registration.
165 +Use `portal agent dashboard` to attach to an already running managed agent. It uses `agent.state_dir` when a config file exists and otherwise falls back to the platform default state directory. With `--foreground` in an interactive terminal, the dashboard attaches in the same process.
166
167 **Subcommands:**
168
docs/src/routes/configuration/+page.md
+1 -2
@@ -152,8 +152,7 @@ The `portal list` subcommand accepts the following flags:
152
153 ### `config.toml`
154
155 -`portal agent run` reads the platform default `config.toml` and starts one managed process for all declared tunnels. Relative paths are resolved from the config file directory.
156 -If the file is missing, `portal agent run` creates a default config and the agent creates the identity file on first tunnel start.
155 +`portal agent run` reads the platform default `config.toml` and starts one managed process for all declared tunnels. Relative paths are resolved from the config file directory. The config file must exist before the agent is started.
156
157 Default paths:
158