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