refactor(config): unify machine config as settings.toml

Consolidate CLI and server machine defaults under settings.toml,
including loader renames, writer preservation fixes, same-machine
manifest handling, and docs/test updates for the new config model.
This commit is contained in:
Bryan Helmkamp 2026-04-05 23:55:08 -04:00
parent c66ba89875
commit 36ff3c5377
No known key found for this signature in database
74 changed files with 811 additions and 381 deletions

View file

@ -20,7 +20,7 @@ services:
environment:
- FABRO_DEMO=1
volumes:
- ./demo-server.toml:/root/.fabro/server.toml:ro
- ./demo-settings.toml:/root/.fabro/settings.toml:ro
- ../apps/fabro-web/public:/app/apps/fabro-web/public
depends_on:
- api

View file

@ -47,7 +47,7 @@ Common flags:
| `--sandbox` | — | Override default sandbox provider |
| `--max-concurrent-runs` | `5` | Maximum concurrent run executions |
See [Server Configuration](/administration/server-configuration) for the full `server.toml` reference.
See [Server Configuration](/administration/server-configuration) for the full `settings.toml` reference.
## Submitting runs
@ -95,7 +95,7 @@ In the server interface, human-in-the-loop questions are served over HTTP instea
## Authentication
The server supports two authentication strategies, configurable in `server.toml`:
The server supports two authentication strategies, configurable in `settings.toml`:
- **JWT** — EdDSA-signed bearer tokens. Used by the web UI. See [API Overview](/api-reference/overview#jwt-bearer-token) for token format.
- **mTLS** — Mutual TLS with client certificates. Used for service-to-service communication. See [API Overview](/api-reference/overview#mtls-mutual-tls) for setup.
@ -108,9 +108,9 @@ Send the `X-Fabro-Demo: 1` header on any API request to get static mock data wit
## Pointing the CLI at a server
The CLI can target a running Fabro server for commands that support a remote API. Configure `~/.fabro/user.toml`:
The CLI can target a running Fabro server for commands that support a remote API. Configure `~/.fabro/settings.toml`:
```toml title="user.toml"
```toml title="settings.toml"
[server]
target = "https://fabro.example.com:3000/api/v1"
```
@ -129,7 +129,7 @@ See [User Configuration](/reference/user-configuration#server-section) for the f
<Columns cols={2}>
<Card title="Server Configuration" icon="gear" href="/administration/server-configuration">
Full server.toml reference — authentication, TLS, run defaults, and more.
Full settings.toml reference — authentication, TLS, run defaults, and more.
</Card>
<Card title="Deploy to Railway" icon="train" href="/administration/deploy-railway">
Step-by-step guide for deploying Fabro on Railway.

View file

@ -11,6 +11,6 @@ Fabro supports three sandbox providers: `local` (no isolation), `docker` (contai
For cloud sandboxes (Daytona), you can control outbound network access with the `network` field in `[sandbox.daytona]`. Three modes are available: `"allow_all"` (default), `"block"`, and `{ allow_list = ["..."] }` for CIDR-based egress filtering.
Server defaults in `server.toml` apply when a run config doesn't specify `network`. Individual run configs can override the server default.
Server defaults in `settings.toml` apply when a run config doesn't specify `network`. Individual run configs can override the server default.
See [Environments — Network access](/execution/environments#network-access) for syntax examples and the full reference.

View file

@ -29,9 +29,9 @@ Fabro is single-tenant software designed for small, trusted teams. The following
### Authentication
- **Enable authentication.** Fabro supports GitHub OAuth and Tailscale header-based auth for the web app. Do not use `insecure_disabled` outside of local development.
- **Configure a username allowlist.** Both GitHub and Tailscale auth support `allowed_usernames` in `server.toml`. An empty allowlist rejects all requests.
- **Configure a username allowlist.** Both GitHub and Tailscale auth support `allowed_usernames` in `settings.toml`. An empty allowlist rejects all requests.
- **Use JWT to connect the web app to the API.** Configure `FABRO_JWT_PRIVATE_KEY` on the web app and `FABRO_JWT_PUBLIC_KEY` on the API server. JWT tokens are Ed25519-signed and short-lived (30 seconds).
- **Use mTLS for machine-to-machine API access.** Configure `[api.tls]` in `server.toml` with server cert, key, and CA. Set client auth to `Required` for programmatic clients (CI, scripts).
- **Use mTLS for machine-to-machine API access.** Configure `[api.tls]` in `settings.toml` with server cert, key, and CA. Set client auth to `Required` for programmatic clients (CI, scripts).
### Secrets

View file

@ -1,17 +1,30 @@
---
title: "Server Configuration"
description: "Server config file, CLI overrides, and environment variables"
description: "Server-owned settings.toml sections, CLI overrides, and environment variables"
---
## Config file
The server config file at `~/.fabro/server.toml` controls how `fabro server start` behaves — API binding, authentication, run defaults, and more. The [Quick Start](/getting-started/quick-start) doesn't require one, but production deployments should configure it explicitly.
`fabro server start` reads `~/.fabro/settings.toml` by default. This is the same file schema used by the CLI.
This file configures the server process itself. It is separate from `[server].target` in `~/.fabro/user.toml`, which tells CLI commands how to connect to a server.
On a same-machine setup, the CLI and server share one `settings.toml`. On a remote deployment, the server machine has its own `settings.toml`, and the client machine keeps a separate local `settings.toml` for CLI-only values such as `[server].target`.
<Note>
Legacy `server.toml`, `user.toml`, and `cli.toml` are ignored with a warning. Rename them to `settings.toml`.
</Note>
### Which sections are server-owned
| Scope | Examples |
|---|---|
| Server-owned | `storage_dir`, `max_concurrent_runs`, `[web]`, `[api]`, `[features]` |
| Shared with CLI and workflow defaults | `[llm]`, `[log]`, `[git]`, `[setup]`, `[sandbox]`, `[checkpoint]`, `[vars]`, `[pull_request]` |
The CLI-only `[server]` section still belongs in the client machine's `settings.toml`. It tells CLI commands where to find a server. The server process does not read `[server].target` for its own binding or routing.
### Full reference
```toml title="server.toml"
```toml title="settings.toml"
# Maximum concurrent workflow runs (default: 5)
max_concurrent_runs = 8
@ -52,7 +65,7 @@ email = "fabro-bot@company.com"
[git.webhooks]
strategy = "tailscale_funnel"
# Run defaults — applied to every run unless overridden by the run config
# Run defaults — applied to every run unless overridden by workflow/project config
[llm]
model = "claude-sonnet-4-5"
provider = "anthropic"
@ -85,7 +98,7 @@ default_branch = "main"
### CLI overrides
Several `server.toml` settings can be overridden via `fabro server start` flags:
Several `settings.toml` settings can be overridden via `fabro server start` flags:
| Flag | Default | Description |
|---|---|---|
@ -95,14 +108,20 @@ Several `server.toml` settings can be overridden via `fabro server start` flags:
| `--provider` | — | Override default LLM provider |
| `--sandbox` | — | Override default sandbox provider |
| `--max-concurrent-runs` | `5` | Maximum concurrent run executions |
| `--config` | `~/.fabro/server.toml` | Path to server config file |
| `--config` | `~/.fabro/settings.toml` | Path to server config file |
| `--dry-run` | — | Execute with simulated LLM backend |
CLI flags take precedence over `server.toml` values. See [Run Configuration — Precedence](/execution/run-configuration#precedence) for the full resolution order.
CLI flags take precedence over `settings.toml` values. See [Run Configuration — Precedence](/execution/run-configuration#precedence) for the full resolution order.
### Run defaults
The `[llm]`, `[setup]`, `[sandbox]`, `[checkpoint]`, and `[vars]` sections in `server.toml` act as defaults for every run. A run config TOML can override any of these. For `[vars]`, Daytona labels, and checkpoint exclude globs, values are **merged** — the run config wins on key collisions. All other fields use "first non-empty wins" precedence.
The `[llm]`, `[setup]`, `[sandbox]`, `[checkpoint]`, and `[vars]` sections in `settings.toml` act as defaults for every run.
On a same-machine setup, `settings.toml` is the shared machine-default layer under `workflow.toml` / `run.toml` and `fabro.toml`.
On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `storage_dir`, `[api]`, `[web]`, `[features]`, and `max_concurrent_runs` always come from the server machine's own `settings.toml` or `fabro server start` flags.
For `[vars]`, Daytona labels, and checkpoint exclude globs, values are **merged** and the more specific layer wins on key collisions. All other fields use "first non-empty wins" precedence.
### `[log]` section
@ -121,7 +140,7 @@ Customize the git author identity used for checkpoint commits. When not set, def
| `name` | Git author name | `"fabro"` |
| `email` | Git author email | `"fabro@local"` |
The CLI can also set `[git.author]` in `user.toml` to override the server default.
On same-machine setups, the CLI and server read the same `[git.author]`. On remote setups, the server uses its local `settings.toml`.
### `[git.webhooks]` section
@ -139,9 +158,9 @@ Configure checkpoint behavior for all runs.
| Key | Description |
|---|---|
| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits (e.g. `["**/node_modules/**"]`) |
| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits (for example, `["**/node_modules/**"]`) |
Exclude globs from `server.toml` and run configs are merged (union, deduplicated). See [Run Configuration — Checkpoint](/execution/run-configuration#checkpoint) for per-run configuration.
Exclude globs from `settings.toml` and run configs are merged (union, deduplicated). See [Run Configuration — Checkpoint](/execution/run-configuration#checkpoint) for per-run configuration.
### `[features]` section

View file

@ -107,7 +107,7 @@ Hooks are defined as `[[hooks]]` entries in any of these TOML config files:
- **`fabro.toml`** — project-level hooks, apply to all workflows in the project
- **`workflow.toml`** — per-workflow hooks
- **`~/.fabro/user.toml`** or **`~/.fabro/server.toml`** — global defaults for all runs
- **`~/.fabro/settings.toml`** or **`~/.fabro/settings.toml`** — global defaults for all runs
See [Merging hook configs](#merging-hook-configs) for how these layers combine.
@ -344,7 +344,7 @@ Command hooks do **not** fail open. A non-zero exit code (other than 0 or 2) pro
Hooks from multiple config files are merged in this order (later layers win on name collisions):
1. **`~/.fabro/user.toml`** or **`~/.fabro/server.toml`** — global defaults
1. **`~/.fabro/settings.toml`** or **`~/.fabro/settings.toml`** — global defaults
2. **`fabro.toml`** — project-level overrides
3. **`workflow.toml`** — per-workflow overrides

View file

@ -30,7 +30,7 @@ For example, a server named `filesystem` exposing a `read_file` tool becomes `mc
MCP servers can be configured in two places:
- **`~/.fabro/user.toml`** — applies to `fabro exec` sessions. See [User Configuration](/reference/user-configuration#mcp_servers-section).
- **`~/.fabro/settings.toml`** — applies to `fabro exec` sessions. See [User Configuration](/reference/user-configuration#mcp_servers-section).
- **Run config TOML** — applies to workflow runs (`fabro run`). See [Run Configuration](/execution/run-configuration#mcp_servers).
Each server entry specifies a transport type and optional timeouts. The server name is the TOML table key and is used in qualified tool names.

View file

@ -17,18 +17,18 @@ The versioned API is served by `fabro server start`, which defaults to:
http://localhost:3000/api/v1
```
The base URL is configurable via `server.toml`:
The base URL is configurable via `settings.toml`:
```toml title="server.toml"
```toml title="settings.toml"
[api]
base_url = "https://fabro.example.com/api/v1"
```
## Authentication
The API supports two authentication strategies, configured in `server.toml`:
The API supports two authentication strategies, configured in `settings.toml`:
```toml title="server.toml"
```toml title="settings.toml"
[api]
authentication_strategies = ["jwt"]
```
@ -58,7 +58,7 @@ Set the verification key via the `FABRO_JWT_PUBLIC_KEY` environment variable (PE
With mTLS, the client authenticates using a TLS client certificate. Configure both the strategy and TLS paths:
```toml title="server.toml"
```toml title="settings.toml"
[api]
authentication_strategies = ["mtls"]
@ -74,7 +74,7 @@ The Common Name (CN) from the client certificate identifies the user.
You can configure both strategies. They are tried in order — the first successful match wins:
```toml title="server.toml"
```toml title="settings.toml"
[api]
authentication_strategies = ["jwt", "mtls"]
```

View file

@ -18,7 +18,7 @@ The run branch is a regular Git branch that grows one commit per completed node.
### Run branch commits
After each node finishes, Fabro stages file changes and creates a commit on the run branch. Files matching `[checkpoint] exclude_globs` patterns (configured in [run.toml](/execution/run-configuration#checkpoint) or [server.toml](/administration/server-configuration#checkpoint-section)) are excluded from staging:
After each node finishes, Fabro stages file changes and creates a commit on the run branch. Files matching `[checkpoint] exclude_globs` patterns (configured in [run.toml](/execution/run-configuration#checkpoint) or [settings.toml](/administration/server-configuration#checkpoint-section)) are excluded from staging:
```
fabro(01JKXYZ...): plan (success)

View file

@ -135,9 +135,9 @@ To skip retro generation for a single run when retros are enabled, pass `--no-re
fabro run workflow.fabro --no-retro
```
Retros can also be enabled server-wide in `server.toml`:
Retros can also be enabled server-wide in `settings.toml`:
```toml title="server.toml"
```toml title="settings.toml"
[features]
retros = true
```

View file

@ -218,7 +218,7 @@ NODE_ENV = "production"
| `"literal"` | Static value passed as-is |
| `"${env.VARNAME}"` | Resolved from the host environment at load time. Missing vars produce a hard error. |
Host env references must be whole-value only — partial interpolation like `"prefix-${env.X}"` is not supported. Sandbox env vars from `server.toml` defaults and the run config are merged, with the run config winning on key collisions.
Host env references must be whole-value only — partial interpolation like `"prefix-${env.X}"` is not supported. Sandbox env vars from `settings.toml` defaults and the run config are merged, with the run config winning on key collisions.
### `[checkpoint]`
@ -233,7 +233,7 @@ exclude_globs = ["**/node_modules/**", "**/.cache/**", "**/dist/**"]
|---|---|
| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits. Uses git pathspec `:(glob,exclude)` syntax. |
Exclude globs from `server.toml` defaults and the run config are merged (union, deduplicated).
Exclude globs from `settings.toml` defaults and the run config are merged (union, deduplicated).
### `[vars]`
@ -391,7 +391,7 @@ Settings can come from multiple sources. Fabro resolves them in this order (firs
| Run config TOML | |
| CLI flags (`--model`, `--provider`, `--sandbox`) | |
| Project defaults (`fabro.toml`) | |
| Server defaults (`~/.fabro/server.toml`) | |
| Server defaults (`~/.fabro/settings.toml`) | |
| Graphviz graph attributes (`default_model`, `default_provider`) | |
| Built-in defaults | Lowest |
@ -423,12 +423,12 @@ Project defaults are merged with run config values using the same rules as serve
### Server defaults
When running via `fabro server start`, the server config at `~/.fabro/server.toml` can set default values for `[llm]`, `[setup]`, `[sandbox]`, and `[vars]`. These defaults are applied to every run unless the run config overrides them.
When running via `fabro server start`, the server config at `~/.fabro/settings.toml` can set default values for `[llm]`, `[setup]`, `[sandbox]`, and `[vars]`. These defaults are applied to every run unless the run config overrides them.
For variables, defaults and run config are **merged** — the run config wins on key collisions:
```toml
# ~/.fabro/server.toml
# ~/.fabro/settings.toml
[vars]
default_key = "from_server"
shared = "from_server"

View file

@ -156,7 +156,7 @@ auto_stop_interval = 30
## Server defaults
When running via `fabro server start`, the server config at `~/.fabro/server.toml` can set default Daytona settings for all runs. Run config TOML values override server defaults. Labels are **merged** — run config labels win on key collisions. The `network` setting uses simple override (run config replaces the server default entirely).
When running via `fabro server start`, the server config at `~/.fabro/settings.toml` can set default Daytona settings for all runs. Run config TOML values override server defaults. Labels are **merged** — run config labels win on key collisions. The `network` setting uses simple override (run config replaces the server default entirely).
See [Server Configuration](/administration/server-configuration) for details.

View file

@ -42,7 +42,7 @@ Fabro uses a [GitHub App](https://docs.github.com/en/apps/overview) to authentic
4. GitHub redirects back to Fabro, which automatically:
- Exchanges the temporary code for permanent app credentials
- Writes `app_id`, `client_id`, and `slug` to `~/.fabro/server.toml`
- Writes `app_id`, `client_id`, and `slug` to `~/.fabro/settings.toml`
- Stores `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, and `GITHUB_APP_PRIVATE_KEY` in the server secret store
- Generates a `SESSION_SECRET` for web app sessions
- Redirects you to the login page
@ -61,8 +61,8 @@ The GitHub App check verifies five fields:
| Field | Source |
|---|---|
| `git.app_id` | `~/.fabro/server.toml` |
| `git.client_id` | `~/.fabro/server.toml` |
| `git.app_id` | `~/.fabro/settings.toml` |
| `git.client_id` | `~/.fabro/settings.toml` |
| `GITHUB_APP_CLIENT_SECRET` | Server secret store |
| `GITHUB_APP_WEBHOOK_SECRET` | Server secret store |
| `GITHUB_APP_PRIVATE_KEY` | Server secret store |
@ -73,9 +73,9 @@ If all five are set, the check passes. If none are set, it warns (GitHub integra
The GitHub App configuration lives in two places:
### `~/.fabro/server.toml`
### `~/.fabro/settings.toml`
```toml title="server.toml"
```toml title="settings.toml"
[git]
provider = "github"
app_id = "123456"
@ -111,11 +111,11 @@ The web app uses the GitHub App's OAuth credentials to authenticate users:
3. User authorizes the app on GitHub
4. GitHub redirects back with an authorization code
5. Fabro exchanges the code for an access token and fetches the user's profile and verified email
6. Fabro checks the username against the `allowed_usernames` list in `server.toml`
6. Fabro checks the username against the `allowed_usernames` list in `settings.toml`
Configure allowed users in `server.toml`:
Configure allowed users in `settings.toml`:
```toml title="server.toml"
```toml title="settings.toml"
[web.auth]
provider = "github"
allowed_usernames = ["alice", "bob"]
@ -191,7 +191,7 @@ The app is installed but doesn't have access to this specific repository. Update
### "GitHub App authentication failed"
The `app_id` in `server.toml` or the `GITHUB_APP_PRIVATE_KEY` environment variable is incorrect. Re-run the setup flow or verify the values match your GitHub App.
The `app_id` in `settings.toml` or the `GITHUB_APP_PRIVATE_KEY` environment variable is incorrect. Re-run the setup flow or verify the values match your GitHub App.
### Clone fails for private repositories

View file

@ -87,7 +87,7 @@ FABRO_SLACK_APP_TOKEN=xapp-your-app-token
Optionally, set a default channel in your [server configuration](/administration/server-configuration):
```toml title="server.toml"
```toml title="settings.toml"
[slack]
default_channel = "#fabro-reviews"
```

View file

@ -32,7 +32,7 @@ fabro server start
### Configuration
The server reads `~/.fabro/server.toml` for default settings (model, sandbox, variables, authentication). This file is live-reloaded — changes take effect within seconds without restarting the server.
The server reads `~/.fabro/settings.toml` for default settings (model, sandbox, variables, authentication). This file is live-reloaded — changes take effect within seconds without restarting the server.
Key server config options:
@ -61,7 +61,7 @@ In API mode, human-in-the-loop questions are served over HTTP instead of termina
### Authentication
API mode supports two authentication strategies, configurable in `server.toml`:
API mode supports two authentication strategies, configurable in `settings.toml`:
- **JWT** — EdDSA-signed tokens (used by the web UI)
- **mTLS** — Mutual TLS with client certificates (used for service-to-service communication)

View file

@ -21,9 +21,9 @@ Connection-target flags like `--storage-dir` and `--server` are command-specific
## Configuration
CLI defaults can be set in `~/.fabro/user.toml` so you don't have to pass common flags every time:
CLI defaults can be set in `~/.fabro/settings.toml` so you don't have to pass common flags every time:
```toml title="user.toml"
```toml title="settings.toml"
[exec]
provider = "anthropic"
model = "claude-opus-4-6"
@ -41,7 +41,7 @@ target = "https://fabro.example.com:3000/api/v1"
`fabro model` uses `[server].target` by default when no explicit `--storage-dir` is passed. `fabro exec` remains a local session unless you pass `--server`, even if `[server].target` is configured.
CLI flags always override `user.toml` values, which override hardcoded defaults.
CLI flags always override `settings.toml` values, which override hardcoded defaults.
---
@ -55,7 +55,7 @@ fabro settings demo
fabro settings run.toml
```
With no argument, Fabro prints the merged ambient defaults from `~/.fabro/user.toml` and the nearest `fabro.toml`.
With no argument, Fabro prints the merged ambient defaults from `~/.fabro/settings.toml` and the nearest `fabro.toml`.
When you pass a workflow name or path:
@ -316,7 +316,7 @@ fabro server start --sandbox daytona --max-concurrent-runs 4
| `--dry-run` | Execute with simulated LLM backend | — |
| `--sandbox <SANDBOX>` | Sandbox for agent tools: `local`, `docker`, or `daytona` | — |
| `--max-concurrent-runs <N>` | Maximum number of concurrent run executions | — |
| `--config <PATH>` | Path to server config file | `~/.fabro/server.toml` |
| `--config <PATH>` | Path to server config file | `~/.fabro/settings.toml` |
Demo mode is per-request: send the `X-Fabro-Demo: 1` header to get static demo data with auth disabled.
@ -715,7 +715,7 @@ fabro upgrade --version 0.6.0
| `--force` | Upgrade even if already on the target version |
| `--dry-run` | Preview what would happen without making changes |
Fabro refuses to downgrade unless you specify an explicit `--version`. A daily background check notifies you when a new version is available — disable it with `upgrade_check = false` in [`user.toml`](/reference/user-configuration#upgrade_check) or the `--no-upgrade-check` global flag.
Fabro refuses to downgrade unless you specify an explicit `--version`. A daily background check notifies you when a new version is available — disable it with `upgrade_check = false` in [`settings.toml`](/reference/user-configuration#upgrade_check) or the `--no-upgrade-check` global flag.
## `fabro artifact list`

View file

@ -1,25 +1,49 @@
---
title: "User Configuration"
description: "Configure default user settings for Fabro with user.toml"
title: "Settings Configuration"
description: "Configure CLI and shared machine defaults with settings.toml"
---
Fabro loads user defaults from `~/.fabro/user.toml` so you don't have to pass common flags every time. The file is optional — if it doesn't exist, built-in defaults are used.
Fabro loads machine defaults from `~/.fabro/settings.toml`. The file is optional. If it does not exist, Fabro falls back to built-in defaults.
On a same-machine setup, the CLI and server both read this file. On a remote setup, each machine has its own `settings.toml` and reads the sections relevant to that process.
<Note>
Legacy `cli.toml`, `user.toml`, and `server.toml` are ignored with a warning. Rename them to `settings.toml`.
</Note>
## File location
The default path is `~/.fabro/user.toml`. Fabro silently skips loading if the file is missing.
The default path is `~/.fabro/settings.toml`.
Use `fabro server start --config /path/to/settings.toml` if the server should read a different file.
## Who reads what
`settings.toml` uses the same schema as `fabro.toml` and `workflow.toml`, but each process only reads the fields it understands.
| Scope | Examples |
|---|---|
| CLI-only | `verbose`, `upgrade_check`, `[server]`, `[exec]`, `[mcp_servers]` |
| Shared defaults | `[llm]`, `[log]`, `[git]`, `[pull_request]`, plus run-default sections like `[setup]`, `[sandbox]`, `[checkpoint]`, and `[vars]` |
| Server-only | `storage_dir`, `max_concurrent_runs`, `[web]`, `[api]`, `[features]` |
See [Server Configuration](/administration/server-configuration) for the server-owned sections.
## Precedence
CLI flags always take the highest priority:
For CLI commands running on the local machine, precedence is:
1. **CLI flags** — always win
2. **`user.toml`** — used when no flag is provided
3. **Built-in defaults** — used when neither flag nor config is set
2. **`workflow.toml` / `run.toml`** — per-run overrides
3. **`fabro.toml`** — project defaults
4. **`settings.toml`** — machine defaults
5. **Built-in defaults**
On a same-machine setup, the server reads the same `settings.toml`. On a remote setup, the CLI machine and server machine each use their own local `settings.toml`.
## Full example
```toml title="user.toml"
```toml title="settings.toml"
verbose = true
upgrade_check = true
@ -67,7 +91,7 @@ url = "https://mcp.sentry.dev/mcp"
Authorization = "Bearer sk-xxx"
```
All fields are optional. You can include just the sections and keys you want to override.
All fields are optional. Include only the sections and keys you want to override. On a same-machine install, this same file can also include server sections such as `[web]` and `[api]`.
## `upgrade_check`
@ -140,7 +164,7 @@ Configure the default log level. Precedence: `FABRO_LOG` env var > `--debug` fla
### `[git.author]`
Customize the git author identity used for checkpoint commits. Overrides the server default when set.
Customize the git author identity used for checkpoint commits. On same-machine setups, the CLI and server read the same `[git.author]` value. On remote setups, each machine uses its own local `settings.toml`.
| Key | Description | Default |
|---|---|---|
@ -149,7 +173,7 @@ Customize the git author identity used for checkpoint commits. Overrides the ser
## `[server]` section
Connection info for commands that can target a remote Fabro server.
Connection info for commands that target a remote Fabro server.
| Key | Description | Default |
|---|---|---|
@ -175,7 +199,7 @@ Optional mTLS configuration for authenticating with the server. When present, th
Paths support `~/` expansion. Example:
```toml title="user.toml"
```toml title="settings.toml"
[server.tls]
cert = "~/.fabro/tls/client.crt"
key = "~/.fabro/tls/client.key"
@ -184,9 +208,9 @@ ca = "~/.fabro/tls/ca.crt"
## `[pull_request]`
Enable auto-PR globally so workflows open a GitHub pull request on successful completion even when running with a `.fabro` file instead of a `run.toml`.
Enable auto-PR globally so workflows open a GitHub pull request on successful completion, even when running with a `.fabro` file instead of a `run.toml`.
```toml title="user.toml"
```toml title="settings.toml"
[pull_request]
enabled = true
```
@ -195,7 +219,7 @@ enabled = true
|---|---|---|
| `enabled` | Automatically create a PR after successful runs | `false` |
Precedence: `run.toml` > `fabro.toml` (project config) > `user.toml` > `server.toml` > built-in default (`false`).
Precedence: `run.toml` > `fabro.toml` > `settings.toml` > built-in default (`false`).
## `[mcp_servers]` section
@ -205,7 +229,7 @@ Configure [MCP servers](/agents/mcp) to connect to during `fabro exec` sessions.
Spawn a local process and communicate over stdin/stdout:
```toml title="user.toml"
```toml title="settings.toml"
[mcp_servers.filesystem]
type = "stdio"
command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
@ -228,7 +252,7 @@ NODE_ENV = "production"
Connect to a remote MCP server over Streamable HTTP:
```toml title="user.toml"
```toml title="settings.toml"
[mcp_servers.sentry]
type = "http"
url = "https://mcp.sentry.dev/mcp"
@ -241,13 +265,13 @@ Authorization = "Bearer sk-xxx"
|---|---|---|
| `type` | Must be `"http"` | — |
| `url` | The MCP server endpoint URL | — |
| `headers` | Optional HTTP headers (e.g., for authentication) | `{}` |
| `headers` | Optional HTTP headers (for example, for authentication) | `{}` |
| `startup_timeout_secs` | Max seconds for the MCP handshake | `10` |
| `tool_timeout_secs` | Max seconds for a single tool call | `60` |
### Sandbox transport
Run an MCP server inside the workflow's sandbox and connect via preview URL. Only available with remote sandbox providers ([Daytona](/integrations/daytona)) that support port previews. Typically configured in [run config TOML](/execution/run-configuration#mcp_servers) rather than `user.toml`.
Run an MCP server inside the workflow's sandbox and connect via preview URL. Only available with remote sandbox providers ([Daytona](/integrations/daytona)) that support port previews. Typically configure this in [run config TOML](/execution/run-configuration#mcp_servers) rather than `settings.toml`.
```toml title="run.toml"
[mcp_servers.playwright]

View file

@ -71,7 +71,7 @@ struct Cli {
pub use fabro_config::user::{OutputFormat, PermissionLevel};
impl AgentArgs {
/// Fill `None` fields from user.toml values, then hardcoded defaults.
/// Fill `None` fields from settings.toml values, then hardcoded defaults.
pub fn apply_cli_defaults(
&mut self,
provider: Option<&str>,

View file

@ -7,10 +7,10 @@ use fabro_workflow::artifacts::{ArtifactEntry, scan_artifacts};
use crate::args::{ArtifactCpArgs, GlobalArgs};
use crate::server_runs::ServerRunLookup;
use crate::shared::{print_json_pretty, split_run_path};
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(super) async fn cp_command(args: &ArtifactCpArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let (run_id, asset_path) = parse_source(&args.source);
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(run_id)?;

View file

@ -5,10 +5,10 @@ use fabro_workflow::artifacts::scan_artifacts;
use crate::args::{ArtifactListArgs, GlobalArgs};
use crate::server_runs::ServerRunLookup;
use crate::shared::format_size;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(super) async fn list_command(args: &ArtifactListArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run_id)?;
let runtime_state = RuntimeState::new(&run.path);

View file

@ -13,7 +13,7 @@ fn merged_config(workflow: Option<&Path>, args: &SettingsArgs) -> anyhow::Result
Some(path) => ConfigLayer::for_workflow(path, &cwd)?,
None => ConfigLayer::project(&cwd)?,
};
let cli = user_config::user_layer_with_storage_dir(args.storage_dir.as_deref())?;
let cli = user_config::settings_layer_with_storage_dir(args.storage_dir.as_deref())?;
base.combine(cli).resolve()
}

View file

@ -5,7 +5,10 @@ use std::sync::LazyLock;
use anyhow::Result;
use fabro_api::types as api_types;
use fabro_config::legacy_env;
use fabro_config::user::{default_user_config_path, legacy_user_config_path};
use fabro_config::user::{
default_settings_path, legacy_old_user_config_path, legacy_server_config_path,
legacy_user_config_path,
};
pub(crate) use fabro_util::check_report::{
CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus,
};
@ -148,45 +151,52 @@ pub(crate) fn check_system_deps(specs: &[DepSpec], outcomes: &[ProbeOutcome]) ->
}
pub(crate) fn check_config(
user_path: Option<PathBuf>,
legacy_path: Option<PathBuf>,
settings_path: Option<PathBuf>,
legacy_paths: &[PathBuf],
) -> CheckResult {
match (user_path, legacy_path) {
(Some(path), None) => CheckResult {
match (settings_path, legacy_paths.is_empty()) {
(Some(path), true) => CheckResult {
name: "Configuration".to_string(),
status: CheckStatus::Pass,
summary: path.display().to_string(),
details: vec![CheckDetail::new(format!("Loaded from {}", path.display()))],
remediation: None,
},
(Some(path), Some(legacy)) => CheckResult {
(Some(path), false) => CheckResult {
name: "Configuration".to_string(),
status: CheckStatus::Warning,
summary: path.display().to_string(),
details: vec![
CheckDetail::new(format!("Loaded from {}", path.display())),
CheckDetail::new(format!("Ignoring legacy config file {}", legacy.display())),
],
remediation: Some(format!("Delete or rename {}", legacy.display())),
details: std::iter::once(CheckDetail::new(format!("Loaded from {}", path.display())))
.chain(legacy_paths.iter().map(|legacy| {
CheckDetail::new(format!("Ignoring legacy config file {}", legacy.display()))
}))
.collect(),
remediation: Some("Delete or rename legacy config files".to_string()),
},
(None, Some(legacy)) => CheckResult {
(None, false) => CheckResult {
name: "Configuration".to_string(),
status: CheckStatus::Warning,
summary: "legacy config file ignored".to_string(),
details: vec![
CheckDetail::new(format!("Found legacy config file {}", legacy.display())),
CheckDetail::new("Rename it to ~/.fabro/user.toml".to_string()),
],
remediation: Some(format!("Rename {} to ~/.fabro/user.toml", legacy.display())),
summary: "legacy config files ignored".to_string(),
details: legacy_paths
.iter()
.map(|legacy| {
CheckDetail::new(format!("Found legacy config file {}", legacy.display()))
})
.chain(std::iter::once(CheckDetail::new(
"Rename one to ~/.fabro/settings.toml or create a new settings.toml"
.to_string(),
)))
.collect(),
remediation: Some("Create ~/.fabro/settings.toml".to_string()),
},
(None, None) => CheckResult {
(None, true) => CheckResult {
name: "Configuration".to_string(),
status: CheckStatus::Warning,
summary: "no user config file found".to_string(),
summary: "no settings config file found".to_string(),
details: vec![CheckDetail::new(
"Create ~/.fabro/user.toml to configure Fabro".to_string(),
"Create ~/.fabro/settings.toml to configure Fabro".to_string(),
)],
remediation: Some("Create ~/.fabro/user.toml".to_string()),
remediation: Some("Create ~/.fabro/settings.toml".to_string()),
},
}
}
@ -304,8 +314,16 @@ pub(crate) async fn run_doctor(
Some(spinner)
};
let user_config_path = default_user_config_path();
let legacy_config_path = legacy_user_config_path();
let settings_config_path = default_settings_path();
let legacy_config_paths = [
legacy_user_config_path(),
legacy_old_user_config_path(),
legacy_server_config_path(),
]
.into_iter()
.flatten()
.filter(|path| path.exists())
.collect::<Vec<_>>();
let legacy_env_path = legacy_env::legacy_env_file_path()
.ok()
.filter(|path| path.exists());
@ -316,8 +334,8 @@ pub(crate) async fn run_doctor(
title: "Local".to_string(),
checks: vec![
check_config(
user_config_path.filter(|path| path.exists()),
legacy_config_path.filter(|path| path.exists()),
settings_config_path.filter(|path| path.exists()),
&legacy_config_paths,
),
check_legacy_env(legacy_env_path),
],
@ -430,21 +448,21 @@ mod tests {
#[test]
fn check_config_pass_with_path() {
let result = check_config(Some(PathBuf::from("/home/user/.fabro/user.toml")), None);
let result = check_config(Some(PathBuf::from("/home/user/.fabro/settings.toml")), &[]);
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.summary.contains(".fabro/user.toml"));
assert!(result.summary.contains(".fabro/settings.toml"));
}
#[test]
fn check_config_warning_without_path() {
let result = check_config(None, None);
let result = check_config(None, &[]);
assert_eq!(result.status, CheckStatus::Warning);
assert!(result.remediation.is_some());
}
#[test]
fn check_config_warning_for_legacy_only_path() {
let result = check_config(None, Some(PathBuf::from("/home/user/.fabro/cli.toml")));
let result = check_config(None, &[PathBuf::from("/home/user/.fabro/cli.toml")]);
assert_eq!(result.status, CheckStatus::Warning);
assert!(result.summary.contains("legacy"));
}

View file

@ -11,7 +11,7 @@ use crate::args::{ExecArgs, GlobalArgs};
use crate::user_config;
pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = user_config::load_user_settings()?;
let cli_settings = user_config::load_settings()?;
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled());
let exec_defaults = cli_settings.exec.as_ref();

View file

@ -11,6 +11,7 @@ use crate::commands::run::output::api_diagnostics_to_local;
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest};
use crate::server_client;
use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, relative_path};
use crate::user_config::{self, load_settings_with_storage_dir};
pub(crate) async fn run(
args: &GraphArgs,
@ -21,6 +22,8 @@ pub(crate) async fn run(
globals.require_no_json()?;
}
let settings = load_settings_with_storage_dir(args.target.storage_dir())?;
let connection = user_config::server_backed_command_connection(&args.target, &settings)?;
let cwd = std::env::current_dir()?;
let built = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
@ -29,7 +32,7 @@ pub(crate) async fn run(
args: None,
run_id: None,
})?;
let client = server_client::connect_server_backed(&args.target).await?;
let client = server_client::connect_server_connection(&connection).await?;
let preflight = client.run_preflight(built.manifest.clone()).await?;
let diagnostics = api_diagnostics_to_local(&preflight.workflow.diagnostics);

View file

@ -14,7 +14,7 @@ use dialoguer::theme::ColorfulTheme;
use dialoguer::{MultiSelect, Select};
use fabro_api::types::SetSecretRequest;
use fabro_config::legacy_env;
use fabro_config::user::USER_CONFIG_FILENAME;
use fabro_config::user::SETTINGS_CONFIG_FILENAME;
use fabro_model::Provider;
use fabro_server::secret_store::SecretStore;
use fabro_util::terminal::Styles;
@ -187,26 +187,72 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> {
// Config TOML generation
// ---------------------------------------------------------------------------
fn root_table_mut(doc: &mut toml::Value) -> Result<&mut toml::Table> {
doc.as_table_mut()
.context("settings.toml root is not a table")
}
fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut toml::Table> {
table
.entry(key.to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::default()))
.as_table_mut()
.with_context(|| format!("settings.toml [{key}] is not a table"))
}
fn merge_server_settings(doc: &mut toml::Value, username: &str) -> Result<()> {
let root = root_table_mut(doc)?;
let web = ensure_table(root, "web")?;
web.insert(
"url".to_string(),
toml::Value::String("http://localhost:3000".to_string()),
);
let auth = ensure_table(web, "auth")?;
auth.insert(
"provider".to_string(),
toml::Value::String("github".to_string()),
);
auth.insert(
"allowed_usernames".to_string(),
toml::Value::Array(vec![toml::Value::String(username.to_string())]),
);
let api = ensure_table(root, "api")?;
api.insert(
"base_url".to_string(),
toml::Value::String("https://localhost:3000/api/v1".to_string()),
);
api.insert(
"authentication_strategies".to_string(),
toml::Value::Array(vec![
toml::Value::String("jwt".to_string()),
toml::Value::String("mtls".to_string()),
]),
);
let tls = ensure_table(api, "tls")?;
tls.insert(
"cert".to_string(),
toml::Value::String("~/.fabro/certs/server.crt".to_string()),
);
tls.insert(
"key".to_string(),
toml::Value::String("~/.fabro/certs/server.key".to_string()),
);
tls.insert(
"ca".to_string(),
toml::Value::String("~/.fabro/certs/ca.crt".to_string()),
);
Ok(())
}
#[cfg(test)]
fn format_config_toml(username: &str) -> String {
format!(
r#"[web]
url = "http://localhost:3000"
[web.auth]
provider = "github"
allowed_usernames = ["{username}"]
[api]
# Public API base URL advertised by the server itself.
base_url = "https://localhost:3000/api/v1"
authentication_strategies = ["jwt", "mtls"]
[api.tls]
cert = "~/.fabro/certs/server.crt"
key = "~/.fabro/certs/server.key"
ca = "~/.fabro/certs/ca.crt"
"#
)
let mut doc = toml::Value::Table(toml::Table::default());
merge_server_settings(&mut doc, username).expect("default server config should be valid");
toml::to_string_pretty(&doc).expect("default server config should serialize")
}
// ---------------------------------------------------------------------------
@ -439,23 +485,23 @@ async fn setup_github_app(
.context("missing 'pem' in GitHub response")?
.to_string();
// Write non-secret config to user.toml
let user_toml_path = fabro_dir.join(USER_CONFIG_FILENAME);
// Write non-secret config to settings.toml
let user_toml_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME);
let existing = std::fs::read_to_string(&user_toml_path).unwrap_or_default();
let mut doc: toml::Value = if existing.is_empty() {
toml::Value::Table(toml::Table::default())
} else {
toml::from_str(&existing).context("failed to parse existing user.toml")?
toml::from_str(&existing).context("failed to parse existing settings.toml")?
};
let table = doc
.as_table_mut()
.context("user.toml root is not a table")?;
.context("settings.toml root is not a table")?;
let git = table
.entry("git")
.or_insert(toml::Value::Table(toml::Table::default()));
let git_table = git
.as_table_mut()
.context("user.toml [git] is not a table")?;
.context("settings.toml [git] is not a table")?;
git_table.insert("app_id".into(), toml::Value::String(app_id));
git_table.insert("slug".into(), toml::Value::String(slug.clone()));
git_table.insert("client_id".into(), toml::Value::String(client_id));
@ -521,8 +567,7 @@ pub(crate) async fn run_install(args: &InstallArgs, globals: &GlobalArgs) -> Res
let web_url = &args.web_url;
let s = Styles::detect_stderr();
let emoji = console::Emoji("⚒️ ", "");
let cli_settings =
user_config::load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = user_config::load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let storage_dir = cli_settings.storage_dir();
let server_was_running = record::active_server_record(&storage_dir).is_some();
@ -693,7 +738,7 @@ pub(crate) async fn run_install(args: &InstallArgs, globals: &GlobalArgs) -> Res
if setup_github {
let github_env_pairs = setup_github_app(&fabro_dir, &s, web_url).await?;
let slug = {
let user_toml_path = fabro_dir.join(USER_CONFIG_FILENAME);
let user_toml_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME);
let toml_content = std::fs::read_to_string(&user_toml_path).unwrap_or_default();
let doc: toml::Value = toml::from_str(&toml_content)
.unwrap_or(toml::Value::Table(toml::Table::default()));
@ -721,10 +766,10 @@ pub(crate) async fn run_install(args: &InstallArgs, globals: &GlobalArgs) -> Res
eprintln!(" {}", s.dim.apply_to("─────────────────────"));
eprintln!();
let config_path = fabro_dir.join("server.toml");
let config_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME);
let write_config = if config_path.exists() {
spawn_blocking(|| {
prompt_confirm("~/.fabro/server.toml already exists. Overwrite?", false)
prompt_confirm("~/.fabro/settings.toml already exists. Overwrite?", false)
})
.await??
} else {
@ -735,14 +780,20 @@ pub(crate) async fn run_install(args: &InstallArgs, globals: &GlobalArgs) -> Res
let username: String =
spawn_blocking(|| prompt_input("GitHub username for allowed access")).await??;
let toml_content = format_config_toml(&username);
std::fs::write(&config_path, &toml_content)?;
let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
let mut doc: toml::Value = if existing.is_empty() {
toml::Value::Table(toml::Table::default())
} else {
toml::from_str(&existing).context("failed to parse existing settings.toml")?
};
merge_server_settings(&mut doc, &username)?;
std::fs::write(&config_path, toml::to_string_pretty(&doc)?)?;
eprintln!(
" {}",
s.dim.apply_to(format!("Wrote {}", config_path.display()))
);
} else {
eprintln!(" {}", s.dim.apply_to("Keeping existing server.toml"));
eprintln!(" {}", s.dim.apply_to("Keeping existing settings.toml"));
}
eprintln!();
}
@ -1005,6 +1056,69 @@ mod tests {
assert_eq!(tls.ca, PathBuf::from("~/.fabro/certs/ca.crt"));
}
#[test]
fn merge_server_settings_preserves_existing_git_table() {
let mut doc: toml::Value = toml::from_str(
r#"
[git]
app_id = "123"
[git.author]
name = "fabro"
email = "fabro@example.com"
"#,
)
.unwrap();
merge_server_settings(&mut doc, "alice").unwrap();
let git = doc.get("git").and_then(toml::Value::as_table).unwrap();
assert_eq!(git.get("app_id").and_then(toml::Value::as_str), Some("123"));
let author = git.get("author").and_then(toml::Value::as_table).unwrap();
assert_eq!(
author.get("name").and_then(toml::Value::as_str),
Some("fabro")
);
assert_eq!(
author.get("email").and_then(toml::Value::as_str),
Some("fabro@example.com")
);
assert_eq!(
doc.get("web")
.and_then(toml::Value::as_table)
.and_then(|web| web.get("auth"))
.and_then(toml::Value::as_table)
.and_then(|auth| auth.get("allowed_usernames"))
.and_then(toml::Value::as_array)
.and_then(|allowed| allowed.first())
.and_then(toml::Value::as_str),
Some("alice")
);
}
#[test]
fn merge_server_settings_preserves_existing_api_nested_keys() {
let mut doc: toml::Value = toml::from_str(
r#"
[api]
base_url = "https://example.com/api/v1"
[api.extra]
mode = "keep-me"
"#,
)
.unwrap();
merge_server_settings(&mut doc, "alice").unwrap();
let api = doc.get("api").and_then(toml::Value::as_table).unwrap();
let extra = api.get("extra").and_then(toml::Value::as_table).unwrap();
assert_eq!(
extra.get("mode").and_then(toml::Value::as_str),
Some("keep-me")
);
}
// -- GitHub App manifest --
#[test]

View file

@ -43,7 +43,7 @@ pub(crate) async fn execute(command: Option<ModelsCommand>, globals: &GlobalArgs
ModelsCommand::List(args) => &args.target,
ModelsCommand::Test(args) => &args.target,
};
let cli_settings = user_config::load_user_settings_with_storage_dir(target_args.storage_dir())?;
let cli_settings = user_config::load_settings_with_storage_dir(target_args.storage_dir())?;
let connection = user_config::model_server_connection(target_args, &cli_settings)?;
let client = server_client::connect_resolved_api_client(&connection).await?;

View file

@ -6,14 +6,14 @@ use tracing::info;
use crate::args::{GlobalArgs, PrCloseArgs};
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(super) async fn close_command(
args: PrCloseArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
globals: &GlobalArgs,
) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let base = runs_base(&cli_settings.storage_dir());
close_from(&base, args, github_app, globals).await
}

View file

@ -12,14 +12,14 @@ use crate::args::{GlobalArgs, PrCreateArgs};
use crate::commands::store::rebuild::rebuild_run_store;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(super) async fn create_command(
args: PrCreateArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
globals: &GlobalArgs,
) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let base = runs_base(&cli_settings.storage_dir());
create_from(&base, args, github_app, globals).await
}

View file

@ -11,7 +11,7 @@ use crate::args::{GlobalArgs, PrListArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
#[derive(Serialize)]
struct PrRow {
@ -27,7 +27,7 @@ pub(super) async fn list_command(
github_app: Option<fabro_github::GitHubAppCredentials>,
globals: &GlobalArgs,
) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let base = runs_base(&cli_settings.storage_dir());
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
list_from(

View file

@ -7,14 +7,14 @@ use fabro_workflow::run_lookup::runs_base;
use crate::args::{GlobalArgs, PrMergeArgs};
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(super) async fn merge_command(
args: PrMergeArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
globals: &GlobalArgs,
) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let base = runs_base(&cli_settings.storage_dir());
merge_from(&base, args, github_app, globals).await
}

View file

@ -13,32 +13,32 @@ use fabro_types::PullRequestRecord;
use crate::args::{GlobalArgs, PrCommand, PrNamespace};
use crate::server_runs::ServerRunLookup;
use crate::shared::github::build_github_app_credentials;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(crate) async fn dispatch(ns: PrNamespace, globals: &GlobalArgs) -> Result<()> {
match ns.command {
PrCommand::Create(args) => {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let github_app = build_github_app_credentials(cli_settings.app_id())?;
Box::pin(create::create_command(args, github_app, globals)).await
}
PrCommand::List(args) => {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let github_app = build_github_app_credentials(cli_settings.app_id())?;
list::list_command(args, github_app, globals).await
}
PrCommand::View(args) => {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let github_app = build_github_app_credentials(cli_settings.app_id())?;
view::view_command(args, github_app, globals).await
}
PrCommand::Merge(args) => {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let github_app = build_github_app_credentials(cli_settings.app_id())?;
merge::merge_command(args, github_app, globals).await
}
PrCommand::Close(args) => {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let github_app = build_github_app_credentials(cli_settings.app_id())?;
close::close_command(args, github_app, globals).await
}

View file

@ -7,14 +7,14 @@ use fabro_workflow::run_lookup::runs_base;
use crate::args::{GlobalArgs, PrViewArgs};
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(super) async fn view_command(
args: PrViewArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
globals: &GlobalArgs,
) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let base = runs_base(&cli_settings.storage_dir());
view_from(&base, args, github_app, globals).await
}

View file

@ -9,12 +9,13 @@ use crate::commands::run::output::{
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, preflight_manifest_args};
use crate::server_client;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::{self, load_settings_with_storage_dir};
pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> anyhow::Result<()> {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_settings = load_user_settings_with_storage_dir(args.target.storage_dir())?;
let cli_settings = load_settings_with_storage_dir(args.target.storage_dir())?;
args.verbose = args.verbose || cli_settings.verbose_enabled();
let connection = user_config::server_backed_command_connection(&args.target, &cli_settings)?;
let cwd = std::env::current_dir()?;
let manifest = build_run_manifest(ManifestBuildInput {
@ -24,7 +25,7 @@ pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> an
args: preflight_manifest_args(&args),
run_id: None,
})?;
let client = server_client::connect_server_backed(&args.target).await?;
let client = server_client::connect_server_connection(&connection).await?;
let response = client.run_preflight(manifest.manifest).await?;
let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics);

View file

@ -4,12 +4,12 @@ use fabro_util::terminal::Styles;
use crate::args::{GlobalArgs, RunArgs};
use crate::server_client;
use crate::shared::print_json_pretty;
use crate::user_config::{self, user_layer_with_storage_dir};
use crate::user_config::{self, settings_layer_with_storage_dir};
pub(crate) async fn execute(mut args: RunArgs, globals: &GlobalArgs) -> Result<()> {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_settings = user_config::load_user_settings_with_storage_dir(args.target.storage_dir())?;
let cli = user_layer_with_storage_dir(args.target.storage_dir())?;
let cli_settings = user_config::load_settings_with_storage_dir(args.target.storage_dir())?;
let cli = settings_layer_with_storage_dir(args.target.storage_dir())?;
args.verbose = args.verbose || cli_settings.verbose_enabled();
let quiet = args.detach;

View file

@ -9,7 +9,7 @@ use tracing::{debug, info};
use crate::args::{CpArgs, GlobalArgs};
use crate::server_runs::ServerRunLookup;
use crate::shared::{print_json_pretty, split_run_path};
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
enum CopyDirection {
Download {
@ -26,7 +26,7 @@ enum CopyDirection {
pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()> {
let direction = parse_direction(&args.src, &args.dst)?;
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
match direction {
CopyDirection::Download {

View file

@ -51,7 +51,6 @@ pub(crate) async fn create_run(
args: run_manifest_args(args),
run_id,
})?;
let connection = user_config::server_backed_command_connection(&args.target, &settings)?;
let client = server_client::connect_server_connection(&connection).await?;
if !quiet {

View file

@ -10,11 +10,11 @@ use crate::args::{DiffArgs, GlobalArgs};
use crate::server_client::RunProjection;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs) -> Result<()> {
info!(run_id = %args.run, "Showing diff");
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let run_id = run.run_id();

View file

@ -9,11 +9,11 @@ use crate::args::{ForkArgs, GlobalArgs};
use crate::commands::store::rebuild::rebuild_run_store;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run_id)?;
let run_id = run.run_id();

View file

@ -4,7 +4,7 @@ use fabro_util::terminal::Styles;
use crate::args::{AttachArgs, GlobalArgs, RunArgs, RunCommands, RunnerArgs, StartArgs};
use crate::server_runs::ServerSummaryLookup;
use crate::shared::print_json_pretty;
use crate::user_config::user_layer_with_storage_dir;
use crate::user_config::settings_layer_with_storage_dir;
pub(crate) mod attach;
pub(crate) mod command;
@ -39,7 +39,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
RunCommands::Create(mut args) => {
apply_json_defaults(&mut args, globals);
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli = user_layer_with_storage_dir(args.target.storage_dir())?;
let cli = settings_layer_with_storage_dir(args.target.storage_dir())?;
let created_run = Box::pin(create::create_run(&args, cli, styles, true)).await?;
if globals.json {
print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?;
@ -90,8 +90,9 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = {
let cli_settings =
load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = crate::user_config::load_settings_with_storage_dir(
args.storage_dir.as_deref(),
)?;
crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled())
};
resume::resume_command(args, styles, globals).await

View file

@ -5,10 +5,10 @@ use tracing::info;
use crate::args::{GlobalArgs, PreviewArgs};
use crate::server_runs::ServerRunLookup;
use crate::shared::{print_json_pretty, validate_daytona_provider};
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(crate) async fn run(args: PreviewArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let record = lookup

View file

@ -3,7 +3,7 @@ use fabro_util::terminal::Styles;
use crate::args::{GlobalArgs, ResumeArgs};
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
/// Resume an interrupted workflow run.
///
@ -15,7 +15,7 @@ pub(crate) async fn resume_command(
styles: &'static Styles,
globals: &GlobalArgs,
) -> anyhow::Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let run_id = run.run_id();

View file

@ -18,7 +18,7 @@ use crate::commands::store::rebuild::rebuild_run_store;
use crate::server_client::ServerStoreClient;
use crate::server_runs::ServerRunLookup;
use crate::shared::{color_if, print_json_pretty};
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
#[derive(Serialize)]
pub(crate) struct TimelineEntryJson {
@ -30,7 +30,7 @@ pub(crate) struct TimelineEntryJson {
pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run_id)?;
let run_id = run.run_id();

View file

@ -6,7 +6,7 @@ use fabro_types::{RunId, RunStatus};
use tokio::time::sleep;
use crate::server_client;
use crate::user_config::load_user_settings;
use crate::user_config::load_settings;
pub(crate) async fn execute(
run_id: RunId,
@ -17,7 +17,7 @@ pub(crate) async fn execute(
let storage_dir = match storage_dir {
Some(storage_dir) => storage_dir,
None => load_user_settings()?.storage_dir(),
None => load_settings()?.storage_dir(),
};
let client = server_client::connect_server(&storage_dir).await?;

View file

@ -5,14 +5,14 @@ use tracing::info;
use crate::args::{GlobalArgs, SshArgs};
use crate::server_runs::ServerRunLookup;
use crate::shared::{print_json_pretty, validate_daytona_provider};
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(crate) async fn run(args: SshArgs, globals: &GlobalArgs) -> Result<()> {
if globals.json && !args.print {
globals.require_no_json()?;
}
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let run_id = run.run_id();

View file

@ -23,8 +23,7 @@ pub(crate) async fn dispatch(command: ServerCommand, _globals: &GlobalArgs) -> R
foreground,
serve_args,
}) => {
let settings =
user_config::load_user_settings_with_storage_dir(storage_dir.as_deref())?;
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
let storage_dir = settings.storage_dir();
let bind_addr = match serve_args.bind.as_deref() {
Some(s) => bind::parse_bind(s)?,
@ -37,15 +36,13 @@ pub(crate) async fn dispatch(command: ServerCommand, _globals: &GlobalArgs) -> R
storage_dir,
timeout,
}) => {
let settings =
user_config::load_user_settings_with_storage_dir(storage_dir.as_deref())?;
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
let storage_dir = settings.storage_dir();
stop::execute(&storage_dir, Duration::from_secs(timeout));
Ok(())
}
ServerCommand::Status(ServerStatusArgs { storage_dir, json }) => {
let settings =
user_config::load_user_settings_with_storage_dir(storage_dir.as_deref())?;
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
let storage_dir = settings.storage_dir();
status::execute(&storage_dir, json)
}
@ -59,8 +56,7 @@ pub(crate) async fn dispatch(command: ServerCommand, _globals: &GlobalArgs) -> R
} else {
// __serve should always receive an explicit --bind from the parent,
// but fall back to the storage dir default if missing.
let settings =
user_config::load_user_settings_with_storage_dir(storage_dir.as_deref())?;
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
Bind::Unix(settings.storage_dir().join("fabro.sock"))
};
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));

View file

@ -12,10 +12,10 @@ use crate::args::{GlobalArgs, StoreDumpArgs};
use crate::commands::store::rebuild::rebuild_run_store;
use crate::server_runs::ServerRunLookup;
use crate::shared::{absolute_or_current, print_json_pretty};
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
pub(crate) async fn dump_command(args: &StoreDumpArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let run_id = run.run_id();

View file

@ -12,7 +12,7 @@ use fabro_workflow::run_status::RunStatus;
use crate::args::{DfArgs, GlobalArgs};
use crate::server_runs::ServerRunLookup;
use crate::shared::{format_size, print_json_pretty};
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
#[derive(Serialize)]
struct SummaryRow {
@ -45,7 +45,7 @@ struct DfOutput {
}
pub(super) async fn df_command(args: &DfArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let data_dir = cli_settings.storage_dir();
let runs_base_dir = runs_base(&data_dir);
let logs_base_dir = logs_base(&data_dir);

View file

@ -12,7 +12,7 @@ use crate::commands::runs::rm::remove_run_with_cleanup;
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::{format_size, print_json_pretty};
use crate::user_config::load_user_settings_with_storage_dir;
use crate::user_config::load_settings_with_storage_dir;
#[derive(Serialize)]
struct PruneRunRow {
@ -23,7 +23,7 @@ struct PruneRunRow {
}
pub(super) async fn prune_command(args: &RunsPruneArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?;
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let base = runs_base(&cli_settings.storage_dir());
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
prune_from(args, lookup.client(), lookup.summaries(), &base, globals).await

View file

@ -7,12 +7,15 @@ use crate::commands::run::output::api_diagnostics_to_local;
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest};
use crate::server_client;
use crate::shared::{print_diagnostics, print_json_pretty, relative_path};
use crate::user_config::{self, load_settings_with_storage_dir};
pub(crate) async fn run(
args: &ValidateArgs,
styles: &Styles,
globals: &GlobalArgs,
) -> anyhow::Result<()> {
let settings = load_settings_with_storage_dir(args.target.storage_dir())?;
let connection = user_config::server_backed_command_connection(&args.target, &settings)?;
let cwd = std::env::current_dir()?;
let built = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
@ -21,7 +24,7 @@ pub(crate) async fn run(
args: None,
run_id: None,
})?;
let client = server_client::connect_server_backed(&args.target).await?;
let client = server_client::connect_server_connection(&connection).await?;
let response = client.run_preflight(built.manifest).await?;
let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics);

View file

@ -14,7 +14,7 @@ mod user_config;
use anyhow::Result;
use args::{Commands, GlobalArgs, LONG_VERSION, RunCommands, ServerCommand, ServerNamespace};
use clap::{CommandFactory, FromArgMatches, Parser, error::ErrorKind, parser::ValueSource};
use fabro_config::server::load_server_settings;
use fabro_config::user::load_settings_config;
use fabro_telemetry::{git, panic as tel_panic, sanitize, sender};
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
@ -229,7 +229,9 @@ async fn main_inner() -> (String, Result<()>) {
}),
}) = command.as_ref()
{
match load_server_settings(args.config.as_deref()) {
match load_settings_config(args.config.as_deref())
.and_then(fabro_types::Settings::try_from)
{
Ok(server_settings) => (
server_settings.log.as_ref().and_then(|l| l.level.clone()),
false,
@ -237,7 +239,7 @@ async fn main_inner() -> (String, Result<()>) {
Err(err) => return (command_name, Err(err)),
}
} else {
match user_config::load_user_settings() {
match user_config::load_settings() {
Ok(cli_settings) => (
cli_settings.log.as_ref().and_then(|l| l.level.clone()),
cli_settings.upgrade_check_enabled(),
@ -295,7 +297,7 @@ async fn main_inner() -> (String, Result<()>) {
commands::server::dispatch(ns.command, &globals).await?;
}
Commands::Doctor(args) => {
let cli_settings = user_config::load_user_settings()?;
let cli_settings = user_config::load_settings()?;
let verbose = args.verbose || cli_settings.verbose_enabled();
let exit_code = commands::doctor::run_doctor(&args, verbose, &globals).await?;
std::process::exit(exit_code);

View file

@ -7,7 +7,7 @@ use fabro_config::ConfigLayer;
use fabro_config::project::{self, discover_project_config, resolve_workflow_path};
use fabro_config::run::parse_run_config;
use fabro_config::sandbox::DockerfileSource;
use fabro_config::user::default_user_config_path;
use fabro_config::user::default_settings_path;
use fabro_graphviz::graph::AttrValue;
use fabro_graphviz::parser;
use fabro_sandbox::daytona::detect_repo_info;
@ -45,7 +45,7 @@ struct WorkflowScanInput {
}
pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
let user_layer = ConfigLayer::user()?;
let user_layer = ConfigLayer::settings()?;
let merged_settings = input
.args_layer
.clone()
@ -86,7 +86,7 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
type_: types::ManifestConfigType::Project,
});
}
if let Some(path) = default_user_config_path().filter(|path| path.is_file()) {
if let Some(path) = default_settings_path().filter(|path| path.is_file()) {
let source = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
configs.push(types::ManifestConfig {

View file

@ -106,14 +106,6 @@ pub(crate) async fn connect_server(storage_dir: &Path) -> Result<ServerStoreClie
})
}
pub(crate) async fn connect_server_backed(
args: &ServerConnectionArgs,
) -> Result<ServerStoreClient> {
Ok(ServerStoreClient {
client: connect_server_backed_api_client(args).await?,
})
}
pub(crate) async fn connect_server_connection(
connection: &user_config::ServerConnection,
) -> Result<ServerStoreClient> {
@ -124,7 +116,7 @@ pub(crate) async fn connect_server_connection(
pub(crate) async fn connect_server_only(args: &ServerTargetArgs) -> Result<ServerStoreClient> {
let storage_dir = std::env::var_os("FABRO_STORAGE_DIR").map(std::path::PathBuf::from);
let settings = user_config::load_user_settings_with_storage_dir(storage_dir.as_deref())?;
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
let connection = user_config::server_only_command_connection(args, &settings)?;
connect_server_connection(&connection).await
}
@ -160,7 +152,7 @@ pub(crate) async fn connect_resolved_api_client(
pub(crate) async fn connect_server_backed_api_client(
args: &ServerConnectionArgs,
) -> Result<fabro_api::Client> {
let settings = user_config::load_user_settings_with_storage_dir(args.storage_dir())?;
let settings = user_config::load_settings_with_storage_dir(args.storage_dir())?;
let connection = user_config::server_backed_command_connection(args, &settings)?;
connect_resolved_api_client(&connection).await
}

View file

@ -9,21 +9,21 @@ use tracing::debug;
use crate::args::{ServerConnectionArgs, ServerTargetArgs};
pub(crate) fn load_user_settings() -> anyhow::Result<Settings> {
ConfigLayer::user()?.resolve()
pub(crate) fn load_settings() -> anyhow::Result<Settings> {
ConfigLayer::settings()?.resolve()
}
pub(crate) fn user_layer_with_storage_dir(
pub(crate) fn settings_layer_with_storage_dir(
storage_dir: Option<&Path>,
) -> anyhow::Result<ConfigLayer> {
let layer = ConfigLayer::user()?;
let layer = ConfigLayer::settings()?;
Ok(apply_storage_dir_override(layer, storage_dir))
}
pub(crate) fn load_user_settings_with_storage_dir(
pub(crate) fn load_settings_with_storage_dir(
storage_dir: Option<&Path>,
) -> anyhow::Result<Settings> {
user_layer_with_storage_dir(storage_dir)?.resolve()
settings_layer_with_storage_dir(storage_dir)?.resolve()
}
pub(crate) fn apply_storage_dir_override(

View file

@ -142,7 +142,7 @@ fn attach_uses_configured_server_target_without_server_flag() {
.body(r#"{"data":[],"meta":{"has_more":false}}"#);
});
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
);

View file

@ -66,7 +66,7 @@ fn parse_settings(stdout: &[u8]) -> Settings {
/// Uses `context.home_dir` for the home directory. Returns project tempdir.
fn setup_settings_fixture(context: &fabro_test::TestContext) -> tempfile::TempDir {
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
r#"
verbose = true
@ -182,7 +182,7 @@ SHARED = "run"
project
}
/// Set up an external workflow fixture with a custom storage_dir in user.toml.
/// Set up an external workflow fixture with a custom storage_dir in settings.toml.
/// Returns (project_tempdir, storage_dir_path).
fn setup_external_workflow_fixture(
context: &mut fabro_test::TestContext,
@ -191,7 +191,7 @@ fn setup_external_workflow_fixture(
context.manage_storage_dir(&storage_dir);
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!(
r#"
storage_dir = "{}"
@ -373,7 +373,7 @@ fn settings_explicit_workflow_path_uses_workflow_project_layers() {
let cwd = tempfile::tempdir().unwrap();
let workflow = project.path().join("workflow.toml");
// Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from user.toml
// Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from settings.toml
let output = context
.settings()
.env_remove("FABRO_STORAGE_DIR")
@ -409,7 +409,7 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
let workflow = project.path().join("workflow.toml");
let run_id = unique_run_id();
// Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from user.toml
// Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from settings.toml
context
.command()
.env_remove("FABRO_STORAGE_DIR")

View file

@ -101,7 +101,7 @@ fn create_uses_configured_server_target_without_server_flag() {
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
);
@ -135,7 +135,7 @@ fn create_storage_dir_suppresses_configured_server_target() {
&context.test_case_id()[..8]
));
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
);
@ -178,7 +178,7 @@ fn create_cli_server_target_overrides_configured_server_target() {
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!(
"[server]\ntarget = \"{}/api/v1\"\n",
config_server.base_url()

View file

@ -99,7 +99,7 @@ fn exec_missing_api_key_exits_with_error() {
fn exec_uses_user_config_defaults() {
let context = test_context!();
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
"[exec]\nprovider = \"openai\"\nmodel = \"gpt-4.1-mini\"\npermissions = \"read-only\"\noutput_format = \"json\"\n",
);
@ -163,7 +163,7 @@ fn exec_configured_server_target_alone_does_not_reroute_exec() {
then.status(500).body("config-should-not-be-used");
});
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
);
@ -205,7 +205,7 @@ fn exec_cli_server_target_overrides_configured_server_target() {
then.status(500).body("cli-override-marker");
});
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!(
"[server]\ntarget = \"{}/api/v1\"\n",
config_server.base_url()

View file

@ -217,7 +217,7 @@ fn list_uses_configured_server_target_without_server_flag() {
);
});
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
);

View file

@ -200,7 +200,7 @@ fn ps_uses_configured_server_target_without_server_flag() {
);
});
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
);

View file

@ -237,7 +237,7 @@ fn rm_uses_configured_server_target_without_local_run_dir() {
then.status(204);
});
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
);

View file

@ -206,7 +206,7 @@ fn detach_uses_configured_server_target_without_server_flag() {
.body(run_status_response(run_id.as_str(), "queued").to_string());
});
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
);
@ -252,7 +252,7 @@ fn detach_storage_dir_suppresses_configured_server_target() {
let local_storage =
std::path::PathBuf::from(format!("/tmp/fabro-run-{}", &context.test_case_id()[..8]));
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
);
@ -311,7 +311,7 @@ fn detach_cli_server_target_overrides_configured_server_target() {
.body(run_status_response(run_id.as_str(), "queued").to_string());
});
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
format!(
"[server]\ntarget = \"{}/api/v1\"\n",
config_server.base_url()

View file

@ -98,7 +98,7 @@ fn runner_uses_snapshotted_app_id_for_github_credentials() {
let workflow_path = context.temp_dir.join("workflow.fabro");
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
"\
version = 1
@ -145,7 +145,7 @@ digraph GitHubApp {
"#
);
context.write_home(".fabro/user.toml", "version = 1\n");
context.write_home(".fabro/settings.toml", "version = 1\n");
let mut cmd = context.command();
cmd.env("GITHUB_APP_PRIVATE_KEY", "%%%not-base64%%%");

View file

@ -50,7 +50,7 @@ fn help() {
--max-concurrent-runs <MAX_CONCURRENT_RUNS>
Maximum number of concurrent run executions
--config <CONFIG>
Path to server config file (default: ~/.fabro/server.toml)
Path to server config file (default: ~/.fabro/settings.toml)
-h, --help
Print help
----- stderr -----

View file

@ -77,7 +77,7 @@ fn conclusion_status(context: &fabro_test::TestContext) -> String {
async fn hook_prompt_proceed_allows_run() {
let context = test_context!();
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
&format!(
r#"
[[hooks]]
@ -126,7 +126,7 @@ model = "{model}"
async fn hook_prompt_block_prevents_run() {
let context = test_context!();
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
&format!(
r#"
[[hooks]]
@ -182,7 +182,7 @@ model = "{model}"
async fn hook_agent_proceed_allows_run() {
let context = test_context!();
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
&format!(
r#"
[[hooks]]
@ -234,7 +234,7 @@ async fn hook_agent_with_tool_use() {
let marker = context.temp_dir.join("hook_check.txt");
std::fs::write(&marker, "READY").unwrap();
context.write_home(
".fabro/user.toml",
".fabro/settings.toml",
&format!(
r#"
[[hooks]]

View file

@ -11,7 +11,7 @@ use crate::run::{
ArtifactsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, SetupConfig,
};
use crate::sandbox::SandboxConfig;
use crate::server::{self, ApiConfig, FeaturesConfig, GitConfig, LogConfig, WebConfig};
use crate::server::{ApiConfig, FeaturesConfig, GitConfig, LogConfig, WebConfig};
use crate::user::{self, ExecConfig, ServerConfig};
use fabro_types::Settings;
@ -21,7 +21,7 @@ fn is_default_checkpoint(c: &CheckpointConfig) -> bool {
/// Unified sparse configuration type for all Fabro config sources.
///
/// Loading functions (`load_user_config`, `load_server_config`, `load_run_config`,
/// Loading functions (`load_settings_config`, `load_run_config`,
/// `parse_project_config`) all return this type. Fields irrelevant to a
/// particular source are left unset (`None` / empty).
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
@ -218,14 +218,9 @@ impl ConfigLayer {
.unwrap_or_default())
}
/// Load user defaults from `~/.fabro/user.toml`.
pub fn user() -> anyhow::Result<Self> {
user::load_user_config(None)
}
/// Load server defaults from `~/.fabro/server.toml`.
pub fn server() -> anyhow::Result<Self> {
server::load_server_config(None)
/// Load machine-level defaults from `~/.fabro/settings.toml`.
pub fn settings() -> anyhow::Result<Self> {
user::load_settings_config(None)
}
/// Convert this combined config layer into final resolved settings.

View file

@ -1,9 +1,8 @@
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use crate::config::ConfigLayer;
use fabro_types::Settings;
pub use fabro_types::settings::server::{
ApiAuthStrategy, ApiSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings,
@ -179,16 +178,6 @@ impl From<LogConfig> for LogSettings {
}
}
/// Load server config from an explicit path or `~/.fabro/server.toml`, returning defaults if the
/// default file doesn't exist. An explicit path that doesn't exist is an error.
pub fn load_server_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer> {
crate::load_config_file(path, "server.toml")
}
pub fn load_server_settings(path: Option<&Path>) -> anyhow::Result<Settings> {
load_server_config(path)?.try_into()
}
/// Resolve the storage directory: config value > default `~/.fabro`.
pub fn resolve_storage_dir(settings: &Settings) -> PathBuf {
settings.storage_dir()

View file

@ -11,8 +11,10 @@ pub use fabro_types::settings::user::{
ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ServerSettings,
};
pub const USER_CONFIG_FILENAME: &str = "user.toml";
pub const SETTINGS_CONFIG_FILENAME: &str = "settings.toml";
pub const LEGACY_USER_CONFIG_FILENAME: &str = "cli.toml";
pub const LEGACY_OLD_USER_CONFIG_FILENAME: &str = "user.toml";
pub const LEGACY_SERVER_CONFIG_FILENAME: &str = "server.toml";
static WARNED_LEGACY_USER_CONFIGS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
@ -77,14 +79,22 @@ impl From<ExecConfig> for ExecSettings {
}
}
pub fn default_user_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".fabro").join(USER_CONFIG_FILENAME))
pub fn default_settings_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".fabro").join(SETTINGS_CONFIG_FILENAME))
}
pub fn legacy_user_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".fabro").join(LEGACY_USER_CONFIG_FILENAME))
}
pub fn legacy_old_user_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".fabro").join(LEGACY_OLD_USER_CONFIG_FILENAME))
}
pub fn legacy_server_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".fabro").join(LEGACY_SERVER_CONFIG_FILENAME))
}
fn warned_legacy_user_configs() -> &'static Mutex<HashSet<PathBuf>> {
WARNED_LEGACY_USER_CONFIGS.get_or_init(|| Mutex::new(HashSet::new()))
}
@ -96,18 +106,25 @@ fn should_warn_about_legacy_user_config(path: &Path) -> bool {
.insert(path.to_path_buf())
}
/// Load user config from an explicit path or `~/.fabro/user.toml`, returning defaults if the
/// Load settings config from an explicit path or `~/.fabro/settings.toml`, returning defaults if the
/// default file doesn't exist. An explicit path that doesn't exist is an error.
#[allow(clippy::print_stderr)]
pub fn load_user_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer> {
pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer> {
if let Some(explicit) = path {
return crate::load_config_file(Some(explicit), USER_CONFIG_FILENAME);
return crate::load_config_file(Some(explicit), SETTINGS_CONFIG_FILENAME);
}
if let Some(legacy_path) = legacy_user_config_path() {
for legacy_path in [
legacy_user_config_path(),
legacy_old_user_config_path(),
legacy_server_config_path(),
]
.into_iter()
.flatten()
{
if legacy_path.is_file() && should_warn_about_legacy_user_config(&legacy_path) {
let target = default_user_config_path()
.unwrap_or_else(|| PathBuf::from(format!("~/.fabro/{USER_CONFIG_FILENAME}")));
let target = default_settings_path()
.unwrap_or_else(|| PathBuf::from(format!("~/.fabro/{SETTINGS_CONFIG_FILENAME}")));
eprintln!(
"Warning: ignoring legacy config file {}. Rename it to {}.",
legacy_path.display(),
@ -116,12 +133,17 @@ pub fn load_user_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer> {
}
}
crate::load_config_file(None, USER_CONFIG_FILENAME)
crate::load_config_file(None, SETTINGS_CONFIG_FILENAME)
}
#[cfg(test)]
mod tests {
use super::should_warn_about_legacy_user_config;
use super::{
LEGACY_OLD_USER_CONFIG_FILENAME, LEGACY_SERVER_CONFIG_FILENAME,
LEGACY_USER_CONFIG_FILENAME, SETTINGS_CONFIG_FILENAME, default_settings_path,
legacy_old_user_config_path, legacy_server_config_path, legacy_user_config_path,
should_warn_about_legacy_user_config,
};
#[test]
fn should_warn_about_legacy_user_config_once_per_path() {
@ -133,4 +155,40 @@ mod tests {
assert!(!should_warn_about_legacy_user_config(&first));
assert!(should_warn_about_legacy_user_config(&second));
}
#[test]
fn settings_paths_use_expected_filenames() {
let home = dirs::home_dir().unwrap();
assert_eq!(
default_settings_path(),
Some(home.join(".fabro").join(SETTINGS_CONFIG_FILENAME))
);
assert_eq!(
legacy_user_config_path(),
Some(home.join(".fabro").join(LEGACY_USER_CONFIG_FILENAME))
);
assert_eq!(
legacy_old_user_config_path(),
Some(home.join(".fabro").join(LEGACY_OLD_USER_CONFIG_FILENAME))
);
assert_eq!(
legacy_server_config_path(),
Some(home.join(".fabro").join(LEGACY_SERVER_CONFIG_FILENAME))
);
}
#[test]
fn should_warn_once_per_legacy_path_even_with_multiple_filenames() {
let dir = tempfile::tempdir().unwrap();
let user = dir.path().join("user.toml");
let server = dir.path().join("server.toml");
let cli = dir.path().join("cli.toml");
assert!(should_warn_about_legacy_user_config(&user));
assert!(!should_warn_about_legacy_user_config(&user));
assert!(should_warn_about_legacy_user_config(&server));
assert!(!should_warn_about_legacy_user_config(&server));
assert!(should_warn_about_legacy_user_config(&cli));
}
}

View file

@ -322,7 +322,7 @@ async fn check_github_app(state: &AppState) -> CheckResult {
status: CheckStatus::Error,
summary: "missing app_id".to_string(),
details: Vec::new(),
remediation: Some("Set git.app_id in server.toml".to_string()),
remediation: Some("Set git.app_id in settings.toml".to_string()),
};
};
let Some(private_key_raw) = private_key_raw else {

View file

@ -37,9 +37,10 @@ pub(crate) struct PreparedManifest {
pub working_directory: PathBuf,
}
pub(crate) fn prepare_manifest(
pub(crate) fn prepare_manifest_with_mode(
server_settings: &Settings,
manifest: &types::RunManifest,
local_daemon_mode: bool,
) -> Result<PreparedManifest> {
if manifest.version != 1 {
bail!("unsupported manifest version {}", manifest.version);
@ -70,7 +71,11 @@ pub(crate) fn prepare_manifest(
.try_fold(ConfigLayer::default(), |layer, config| {
Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer))
})?;
let server_defaults = server_defaults_layer(server_settings)?;
let server_defaults = if local_daemon_mode {
local_daemon_server_overrides_layer(server_settings)?
} else {
server_defaults_layer(server_settings)?
};
let mut settings = args_layer
.combine(workflow_layer)
@ -233,6 +238,18 @@ fn server_defaults_layer(settings: &Settings) -> Result<ConfigLayer> {
Ok(layer)
}
fn local_daemon_server_overrides_layer(settings: &Settings) -> Result<ConfigLayer> {
let layer = server_defaults_layer(settings)?;
Ok(ConfigLayer {
storage_dir: layer.storage_dir,
max_concurrent_runs: layer.max_concurrent_runs,
web: layer.web,
api: layer.api,
features: layer.features,
..Default::default()
})
}
fn strip_server_owned_fields(layer: &mut ConfigLayer) {
layer.server = None;
layer.exec = None;
@ -790,7 +807,8 @@ mod tests {
..Default::default()
};
let prepared = prepare_manifest(&server_settings, &minimal_manifest()).unwrap();
let prepared =
prepare_manifest_with_mode(&server_settings, &minimal_manifest(), false).unwrap();
assert_eq!(prepared.settings.dry_run, None);
assert_eq!(
@ -819,8 +837,67 @@ mod tests {
verbose: None,
});
let prepared = prepare_manifest(&server_settings, &manifest).unwrap();
let prepared = prepare_manifest_with_mode(&server_settings, &manifest, false).unwrap();
assert_eq!(prepared.settings.dry_run, Some(true));
}
#[test]
fn prepare_manifest_local_daemon_prefers_bundled_settings_without_duplication() {
let server_settings: Settings = toml::from_str(
r#"
storage_dir = "/srv/fabro"
[setup]
commands = ["cli-setup"]
[git]
app_id = "snapshotted-app-id"
"#,
)
.unwrap();
let mut manifest = minimal_manifest();
manifest.workflows.get_mut("workflow.fabro").unwrap().config =
Some(types::ManifestWorkflowConfig {
path: "workflow.toml".to_string(),
source: r#"
version = 1
[setup]
commands = ["workflow-setup"]
"#
.to_string(),
});
manifest.configs.push(types::ManifestConfig {
path: Some("/tmp/home/.fabro/settings.toml".to_string()),
source: Some(
r#"
[setup]
commands = ["cli-setup"]
[git]
app_id = "snapshotted-app-id"
"#
.to_string(),
),
type_: types::ManifestConfigType::User,
});
let prepared = prepare_manifest_with_mode(&server_settings, &manifest, true).unwrap();
assert_eq!(
prepared
.settings
.setup
.as_ref()
.map(|setup| setup.commands.clone()),
Some(vec!["workflow-setup".to_string(), "cli-setup".to_string(),])
);
assert_eq!(prepared.settings.app_id(), Some("snapshotted-app-id"));
assert_eq!(
prepared.settings.storage_dir,
Some(PathBuf::from("/srv/fabro"))
);
}
}

View file

@ -2,7 +2,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use fabro_config::server::{load_server_settings, resolve_storage_dir};
use fabro_config::server::resolve_storage_dir;
use fabro_config::user::{default_settings_path, load_settings_config};
use fabro_util::terminal::Styles;
use object_store::local::LocalFileSystem;
use tokio::net::{TcpListener, UnixListener};
@ -48,11 +49,21 @@ pub struct ServeArgs {
#[arg(long)]
pub max_concurrent_runs: Option<usize>,
/// Path to server config file (default: ~/.fabro/server.toml)
/// Path to server config file (default: ~/.fabro/settings.toml)
#[arg(long)]
pub config: Option<PathBuf>,
}
fn load_settings(path: Option<&Path>) -> anyhow::Result<Settings> {
load_settings_config(path)?.try_into()
}
fn resolved_config_path(path: Option<&Path>) -> PathBuf {
path.map(Path::to_path_buf)
.or_else(default_settings_path)
.unwrap_or_else(|| PathBuf::from(".fabro/settings.toml"))
}
fn apply_serve_overrides(base: &Settings, args: &ServeArgs, dry_run_mode: bool) -> Settings {
let mut settings = base.clone();
if dry_run_mode {
@ -93,7 +104,8 @@ pub async fn serve_command(
storage_dir_override: Option<PathBuf>,
) -> anyhow::Result<()> {
let config_path = args.config.clone();
let disk_settings = load_server_settings(config_path.as_deref())?;
let disk_settings = load_settings(config_path.as_deref())?;
let active_config_path = resolved_config_path(config_path.as_deref());
let data_dir = storage_dir_override.unwrap_or_else(|| resolve_storage_dir(&disk_settings));
let secret_store_path = data_dir.join("secrets.json");
let secret_store = SecretStore::load(secret_store_path.clone())?;
@ -169,6 +181,8 @@ pub async fn serve_command(
max_concurrent_runs,
store,
secret_store_path,
active_config_path,
matches!(&auth_mode, AuthMode::Disabled),
)?;
spawn_scheduler(Arc::clone(&state));
let router = build_router(Arc::clone(&state), auth_mode);
@ -249,7 +263,7 @@ pub async fn serve_command(
interval.tick().await; // skip first immediate tick
loop {
interval.tick().await;
match load_server_settings(config_path_for_poll.as_deref()) {
match load_settings(config_path_for_poll.as_deref()) {
Ok(new_disk_settings) => {
let effective = apply_runtime_settings(
&new_disk_settings,

View file

@ -209,6 +209,8 @@ pub struct AppState {
pub sessions: SessionStore,
pub(crate) secret_store: AsyncRwLock<SecretStore>,
pub(crate) settings: Arc<RwLock<Settings>>,
pub(crate) config_path: PathBuf,
pub(crate) local_daemon_mode: bool,
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
}
@ -849,6 +851,8 @@ pub fn create_app_state_with_settings_and_registry_factory(
5,
test_store(),
test_secret_store_path(),
test_config_path(),
false,
)
.expect("test app state should build")
}
@ -884,6 +888,8 @@ pub fn create_app_state_with_store(
max_concurrent_runs,
store,
test_secret_store_path(),
test_config_path(),
false,
)
.expect("test app state should build")
}
@ -894,6 +900,8 @@ pub(crate) fn build_app_state_with_path(
max_concurrent_runs: usize,
store: StoreHandle,
secret_store_path: PathBuf,
config_path: PathBuf,
local_daemon_mode: bool,
) -> anyhow::Result<Arc<AppState>> {
let secret_store = SecretStore::load(secret_store_path)?;
Ok(Arc::new(AppState {
@ -905,6 +913,8 @@ pub(crate) fn build_app_state_with_path(
sessions: new_session_store(),
secret_store: AsyncRwLock::new(secret_store),
settings,
config_path,
local_daemon_mode,
registry_factory_override,
}))
}
@ -913,6 +923,10 @@ fn test_secret_store_path() -> PathBuf {
std::env::temp_dir().join(format!("fabro-test-secrets-{}.json", Ulid::new()))
}
fn test_config_path() -> PathBuf {
std::env::temp_dir().join(format!("fabro-test-settings-{}.toml", Ulid::new()))
}
async fn list_board_runs(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
@ -1134,7 +1148,11 @@ async fn create_run(
State(state): State<Arc<AppState>>,
Json(req): Json<RunManifest>,
) -> Response {
let prepared = match run_manifest::prepare_manifest(&state.settings.read().unwrap(), &req) {
let prepared = match run_manifest::prepare_manifest_with_mode(
&state.settings.read().unwrap(),
&req,
state.local_daemon_mode,
) {
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
@ -1191,7 +1209,11 @@ async fn run_preflight(
State(state): State<Arc<AppState>>,
Json(req): Json<RunManifest>,
) -> Response {
let prepared = match run_manifest::prepare_manifest(&state.settings.read().unwrap(), &req) {
let prepared = match run_manifest::prepare_manifest_with_mode(
&state.settings.read().unwrap(),
&req,
state.local_daemon_mode,
) {
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
@ -1217,11 +1239,14 @@ async fn render_graph_from_manifest(
State(state): State<Arc<AppState>>,
Json(req): Json<RenderWorkflowGraphRequest>,
) -> Response {
let prepared =
match run_manifest::prepare_manifest(&state.settings.read().unwrap(), &req.manifest) {
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let prepared = match run_manifest::prepare_manifest_with_mode(
&state.settings.read().unwrap(),
&req.manifest,
state.local_daemon_mode,
) {
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let validated = match run_manifest::validate_prepared_manifest(&prepared) {
Ok(validated) => validated,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),

View file

@ -1,5 +1,6 @@
use std::sync::Arc;
use anyhow::{Context, anyhow};
use axum::extract::{Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Redirect, Response};
@ -488,10 +489,7 @@ async fn setup_register(
);
};
let settings_path = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".fabro")
.join("server.toml");
let settings_path = state.config_path.clone();
let mut settings = state
.settings
@ -508,11 +506,33 @@ async fn setup_register(
if let Some(parent) = settings_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let toml = build_server_toml(&settings, &git);
if let Err(error) = std::fs::write(&settings_path, toml) {
let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
let mut doc: toml::Value = if existing.is_empty() {
toml::Value::Table(toml::Table::default())
} else {
match toml::from_str(&existing).context("failed to parse existing settings config") {
Ok(doc) => doc,
Err(error) => {
return json_response(
StatusCode::INTERNAL_SERVER_ERROR,
json!({"error": format!("Failed to parse settings config: {error}")}),
);
}
}
};
if let Err(error) = merge_settings_keys(&mut doc, &settings, &git) {
return json_response(
StatusCode::INTERNAL_SERVER_ERROR,
json!({"error": format!("Failed to write server config: {error}")}),
json!({"error": format!("Failed to update settings config: {error}")}),
);
}
if let Err(error) = std::fs::write(
&settings_path,
toml::to_string_pretty(&doc).unwrap_or_default(),
) {
return json_response(
StatusCode::INTERNAL_SERVER_ERROR,
json!({"error": format!("Failed to write settings config: {error}")}),
);
}
@ -544,7 +564,24 @@ async fn setup_register(
Json(json!({"ok": true, "restart_required": true})).into_response()
}
fn build_server_toml(settings: &Settings, git: &GitSettings) -> String {
fn root_table_mut(doc: &mut toml::Value) -> anyhow::Result<&mut toml::Table> {
doc.as_table_mut()
.ok_or_else(|| anyhow!("settings config root is not a table"))
}
fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> anyhow::Result<&'a mut toml::Table> {
table
.entry(key.to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::default()))
.as_table_mut()
.ok_or_else(|| anyhow!("settings config [{key}] is not a table"))
}
fn merge_settings_keys(
doc: &mut toml::Value,
settings: &Settings,
git: &GitSettings,
) -> anyhow::Result<()> {
let web_url = settings.web.as_ref().map_or_else(
|| "http://localhost:3000".to_string(),
|web| web.url.clone(),
@ -556,73 +593,136 @@ fn build_server_toml(settings: &Settings, git: &GitSettings) -> String {
.unwrap_or_default();
let api = settings.api.clone().unwrap_or_default();
let mut value = toml::Table::new();
value.insert(
"web".to_string(),
toml::Value::Table({
let mut web = toml::Table::new();
web.insert("url".to_string(), toml::Value::String(web_url));
web.insert(
"auth".to_string(),
toml::Value::Table({
let mut auth = toml::Table::new();
auth.insert(
"provider".to_string(),
toml::Value::String("github".to_string()),
);
auth.insert(
"allowed_usernames".to_string(),
toml::Value::Array(allowed.into_iter().map(toml::Value::String).collect()),
);
auth
}),
);
web
}),
let root = root_table_mut(doc)?;
let web = ensure_table(root, "web")?;
web.insert("url".to_string(), toml::Value::String(web_url));
let auth = ensure_table(web, "auth")?;
auth.insert(
"provider".to_string(),
toml::Value::String("github".to_string()),
);
value.insert(
"api".to_string(),
toml::Value::Table({
let mut api_table = toml::Table::new();
api_table.insert("base_url".to_string(), toml::Value::String(api.base_url));
api_table.insert(
"authentication_strategies".to_string(),
toml::Value::Array(
api.authentication_strategies
.iter()
.map(|strategy| match strategy {
ApiAuthStrategy::Jwt => "jwt",
ApiAuthStrategy::Mtls => "mtls",
})
.map(|value| toml::Value::String(value.to_string()))
.collect(),
),
);
api_table
}),
auth.insert(
"allowed_usernames".to_string(),
toml::Value::Array(allowed.into_iter().map(toml::Value::String).collect()),
);
value.insert(
"git".to_string(),
toml::Value::Table({
let mut git_table = toml::Table::new();
git_table.insert(
"provider".to_string(),
toml::Value::String("github".to_string()),
);
git_table.insert(
"app_id".to_string(),
toml::Value::String(git.app_id.clone().unwrap_or_default()),
);
git_table.insert(
"client_id".to_string(),
toml::Value::String(git.client_id.clone().unwrap_or_default()),
);
git_table.insert(
"slug".to_string(),
toml::Value::String(git.slug.clone().unwrap_or_default()),
);
git_table
}),
let api_table = ensure_table(root, "api")?;
api_table.insert("base_url".to_string(), toml::Value::String(api.base_url));
api_table.insert(
"authentication_strategies".to_string(),
toml::Value::Array(
api.authentication_strategies
.iter()
.map(|strategy| match strategy {
ApiAuthStrategy::Jwt => "jwt",
ApiAuthStrategy::Mtls => "mtls",
})
.map(|value| toml::Value::String(value.to_string()))
.collect(),
),
);
toml::to_string(&value).unwrap_or_default()
let git_table = ensure_table(root, "git")?;
git_table.insert(
"provider".to_string(),
toml::Value::String("github".to_string()),
);
git_table.insert(
"app_id".to_string(),
toml::Value::String(git.app_id.clone().unwrap_or_default()),
);
git_table.insert(
"client_id".to_string(),
toml::Value::String(git.client_id.clone().unwrap_or_default()),
);
git_table.insert(
"slug".to_string(),
toml::Value::String(git.slug.clone().unwrap_or_default()),
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::merge_settings_keys;
use fabro_types::Settings;
#[test]
fn merge_settings_keys_preserves_unrelated_git_nested_keys() {
let mut doc: toml::Value = toml::from_str(
r#"
[git]
provider = "github"
[git.author]
name = "fabro"
email = "fabro@example.com"
[git.webhooks]
strategy = "tailscale_funnel"
"#,
)
.unwrap();
let mut settings = Settings::default();
settings.web.get_or_insert_default().auth.allowed_usernames = vec!["alice".to_string()];
settings.git.get_or_insert_default().provider = fabro_config::server::GitProvider::Github;
settings.git.get_or_insert_default().app_id = Some("123".to_string());
settings.git.get_or_insert_default().client_id = Some("abc".to_string());
settings.git.get_or_insert_default().slug = Some("fabro".to_string());
merge_settings_keys(&mut doc, &settings, settings.git.as_ref().unwrap()).unwrap();
let git = doc.get("git").and_then(toml::Value::as_table).unwrap();
assert_eq!(git.get("app_id").and_then(toml::Value::as_str), Some("123"));
let author = git.get("author").and_then(toml::Value::as_table).unwrap();
assert_eq!(
author.get("name").and_then(toml::Value::as_str),
Some("fabro")
);
let webhooks = git.get("webhooks").and_then(toml::Value::as_table).unwrap();
assert_eq!(
webhooks.get("strategy").and_then(toml::Value::as_str),
Some("tailscale_funnel")
);
}
#[test]
fn merge_settings_keys_preserves_unrelated_top_level_sections() {
let mut doc: toml::Value = toml::from_str(
r#"
[exec]
provider = "anthropic"
[server]
target = "https://fabro.example.com/api/v1"
"#,
)
.unwrap();
let mut settings = Settings::default();
settings.web.get_or_insert_default().auth.allowed_usernames = vec!["alice".to_string()];
settings.git.get_or_insert_default().provider = fabro_config::server::GitProvider::Github;
settings.git.get_or_insert_default().app_id = Some("123".to_string());
settings.git.get_or_insert_default().client_id = Some("abc".to_string());
settings.git.get_or_insert_default().slug = Some("fabro".to_string());
merge_settings_keys(&mut doc, &settings, settings.git.as_ref().unwrap()).unwrap();
assert_eq!(
doc.get("exec")
.and_then(toml::Value::as_table)
.and_then(|exec| exec.get("provider"))
.and_then(toml::Value::as_str),
Some("anthropic")
);
assert_eq!(
doc.get("server")
.and_then(toml::Value::as_table)
.and_then(|server| server.get("target"))
.and_then(toml::Value::as_str),
Some("https://fabro.example.com/api/v1")
);
}
}

View file

@ -136,9 +136,9 @@ async fn create_env_with_github_app(
}
fn load_github_app_credentials() -> fabro_github::GitHubAppCredentials {
// Read app_id from ~/.fabro/server.toml
// Read app_id from ~/.fabro/settings.toml
let home = dirs::home_dir().expect("No home directory");
let config_path = home.join(".fabro/server.toml");
let config_path = home.join(".fabro/settings.toml");
let config_str = std::fs::read_to_string(&config_path)
.unwrap_or_else(|e| panic!("Failed to read {}: {e}", config_path.display()));
@ -152,11 +152,11 @@ fn load_github_app_credentials() -> fabro_github::GitHubAppCredentials {
app_id: Option<String>,
}
let config: Config = toml::from_str(&config_str).expect("Failed to parse server.toml");
let config: Config = toml::from_str(&config_str).expect("Failed to parse settings.toml");
let app_id = config
.git
.app_id
.expect("app_id not set in server.toml [git] section");
.expect("app_id not set in settings.toml [git] section");
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").expect("GITHUB_APP_PRIVATE_KEY not set");
let private_key_pem = if raw.starts_with("-----") {