Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ curl http://localhost:3000/api/v1/health # backend (via web proxy)

```text
src/synthorg/
api/ # Litestar REST + WebSocket API (controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation)), AppState hot-reload slots (provider_registry, model_router with swap methods, provider_management), settings dispatcher lifecycle, logging bootstrap (_bootstrap_app_logging, SYNTHORG_LOG_DIR env var override, called before all other setup in create_app), service auto-wiring (auto_wire.py: Phase 1 at construction -- message bus/cost tracker/provider registry/task engine; Phase 2 in on_startup after persistence connects -- settings service + config resolver + provider management), lifecycle helpers (lifecycle.py: _safe_startup, _safe_shutdown, _cleanup_on_failure, _init_persistence, _try_stop)
api/ # Litestar REST + WebSocket API (controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, setup endpoint (first-run wizard: status check, template listing, company/agent creation, completion gate), RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation)), AppState hot-reload slots (provider_registry, model_router with swap methods, provider_management), settings dispatcher lifecycle, logging bootstrap (_bootstrap_app_logging, SYNTHORG_LOG_DIR env var override, called before all other setup in create_app), service auto-wiring (auto_wire.py: Phase 1 at construction -- message bus/cost tracker/provider registry/task engine; Phase 2 in on_startup after persistence connects -- settings service + config resolver + provider management), lifecycle helpers (lifecycle.py: _safe_startup, _safe_shutdown, _cleanup_on_failure, _init_persistence, _try_stop)
auth/ # Authentication subpackage (controller, service, middleware, JWT + API key + WS ticket store, models, config, secret resolution)
backup/ # Backup and restore -- scheduled/manual/lifecycle backups of persistence DB, agent memory, and company config. BackupService orchestrator, BackupScheduler (periodic asyncio task), RetentionManager (count + age pruning), tar.gz compression, SHA-256 checksums, manifest tracking, validated restore with atomic rollback and safety backup
handlers/ # ComponentHandler protocol + concrete handlers: PersistenceComponentHandler (SQLite VACUUM INTO), MemoryComponentHandler (copytree), ConfigComponentHandler (copy2)
Expand All @@ -140,18 +140,18 @@ src/synthorg/
web/ # Vue 3 + PrimeVue + Tailwind CSS dashboard
src/
api/ # Axios client, endpoint modules, TypeScript types (mirrors backend Pydantic models)
components/ # Vue components organized by feature (agents/, approvals/, budget/, common/, dashboard/, layout/, messages/, org-chart/, providers/, tasks/)
components/ # Vue components organized by feature (agents/, approvals/, budget/, common/, dashboard/, layout/, messages/, org-chart/, providers/, setup/, tasks/)
composables/ # Reusable composition functions (useAuth, useLoginLockout, usePolling, useOptimisticUpdate, useWebSocketSubscription)
router/ # Vue Router config with auth guards
stores/ # Pinia stores (auth, agents, tasks, budget, messages, meetings, approvals, websocket, analytics, company, providers)
stores/ # Pinia stores (auth, agents, tasks, budget, messages, meetings, approvals, websocket, analytics, company, providers, setup)
styles/ # Global CSS and PrimeVue theme configuration
utils/ # Constants, formatters, error helpers
views/ # Page-level components (LoginPage, SetupPage, DashboardPage, OrgChartPage, TaskBoardPage, MessageFeedPage, ApprovalQueuePage, AgentProfilesPage, AgentDetailPage, BudgetPanelPage, MeetingLogsPage, ArtifactBrowserPage, SettingsPage)
__tests__/ # Vitest unit tests (organized by feature)

cli/ # Go CLI binary (cross-platform, manages Docker lifecycle)
main.go # Entry point
cmd/ # Cobra commands (init, start, stop, status, logs, doctor, update, uninstall, version, config, completion-install, backup); root flags: --data-dir, --skip-verify
cmd/ # Cobra commands (init, start, stop, status, logs, doctor, update, uninstall, version, config, completion-install, backup, setup); root flags: --data-dir, --skip-verify
internal/
version/ # Build-time version vars (ldflags-injected)
config/ # Data dir resolution (XDG/macOS/Windows), persisted state (JSON)
Expand Down Expand Up @@ -199,7 +199,7 @@ site/ # Astro landing page (synthorg.io)
- **Every module** with business logic MUST have: `from synthorg.observability import get_logger` then `logger = get_logger(__name__)`
- **Never** use `import logging` / `logging.getLogger()` / `print()` in application code
- **Variable name**: always `logger` (not `_logger`, not `log`)
- **Event names**: always use constants from the domain-specific module under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`, `GIT_COMMAND_START` from `events.git`, `CONTEXT_BUDGET_FILL_UPDATED` from `events.context_budget`, `BACKUP_STARTED` from `events.backup`). Each domain has its own module -- see `src/synthorg/observability/events/` for the full inventory of constants. Import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`
- **Event names**: always use constants from the domain-specific module under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`, `GIT_COMMAND_START` from `events.git`, `CONTEXT_BUDGET_FILL_UPDATED` from `events.context_budget`, `BACKUP_STARTED` from `events.backup`, `SETUP_COMPLETED` from `events.setup`). Each domain has its own module -- see `src/synthorg/observability/events/` for the full inventory of constants. Import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`
- **Structured kwargs**: always `logger.info(EVENT, key=value)` — never `logger.info("msg %s", val)`
- **All error paths** must log at WARNING or ERROR with context before raising
- **All state transitions** must log at INFO
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,11 @@ synthorg init # interactive setup wizard
synthorg start # pull images + start containers
synthorg status # check health
synthorg doctor # diagnostics if something is wrong
synthorg setup # re-run first-run setup wizard
```

Open [http://localhost:3000](http://localhost:3000) after `synthorg start` -- on a fresh install, the **setup wizard** guides you through creating an admin account, configuring an LLM provider, naming your company, and hiring your first agent.

### Development (from source)

```bash
Expand Down
153 changes: 153 additions & 0 deletions cli/cmd/setup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package cmd

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"

"github.com/Aureliolo/synthorg/cli/internal/config"
"github.com/Aureliolo/synthorg/cli/internal/docker"
"github.com/Aureliolo/synthorg/cli/internal/ui"
"github.com/spf13/cobra"
)

// setupClient is the shared HTTP client for setup API requests.
// Per-request timeouts are controlled via context.WithTimeout.
var setupClient = &http.Client{
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}

var setupCmd = &cobra.Command{
Use: "setup",
Short: "Re-open the first-run setup wizard",
Long: `Reset the setup_complete flag and open the setup wizard in the browser.

This is useful when you want to re-configure providers, company settings,
or add agents through the guided setup flow. Requires the SynthOrg stack
to be running ('synthorg start').`,
RunE: runSetup,
}

func init() {
rootCmd.AddCommand(setupCmd)
}

func runSetup(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
dir := resolveDataDir()

state, err := config.Load(dir)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

safeDir, err := safeStateDir(state)
if err != nil {
return err
}
composePath := filepath.Join(safeDir, "compose.yml")
if _, err := os.Stat(composePath); errors.Is(err, os.ErrNotExist) {
Comment thread Fixed
Comment thread Dismissed
return fmt.Errorf("compose.yml not found in %s -- run 'synthorg init' first", safeDir)
}

out := ui.NewUI(cmd.OutOrStdout())
errOut := ui.NewUI(cmd.ErrOrStderr())

// Verify Docker is available and containers are running.
info, err := docker.Detect(ctx)
if err != nil {
return err
}

psOut, err := docker.ComposeExecOutput(ctx, info, safeDir, "ps", "--format", "json")
if err != nil || psOut == "" || psOut == "[]" || psOut == "[]\n" {
return fmt.Errorf("no containers running -- run 'synthorg start' first")
}
Comment on lines +72 to +75
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the file and the specific lines in question
cat -n cli/cmd/setup.go | head -80

Repository: Aureliolo/synthorg

Length of output: 2768


🏁 Script executed:

# Find the docker package definition
fd -t f "\.go$" | xargs grep -l "func.*ComposeExecOutput" | head -5

Repository: Aureliolo/synthorg

Length of output: 126


🏁 Script executed:

# Check if there are any tests or documentation about Docker output
rg "docker.*ps.*format.*json" -A 3 -B 3

Repository: Aureliolo/synthorg

Length of output: 1883


🏁 Script executed:

# Let's look at the docker.ComposeExecOutput function
cat -n cli/internal/docker/client.go | head -150

Repository: Aureliolo/synthorg

Length of output: 6252


🏁 Script executed:

# Check the parseContainerDetails function to understand JSON parsing
cat -n cli/internal/diagnostics/collect.go | grep -A 30 "func parseContainerDetails"

Repository: Aureliolo/synthorg

Length of output: 1053


🏁 Script executed:

# Let's also look at the test file to understand expected behavior
cat -n cli/internal/docker/client_test.go

Repository: Aureliolo/synthorg

Length of output: 5864


Parse the JSON output instead of checking for an empty string.

When docker compose ps --format json has no containers running, it returns an empty JSON array ([]), which is non-empty. The current check psOut == "" does not catch this case and allows the setup flow to proceed incorrectly. Either parse the JSON (as done in diagnostics.parseContainerDetails) or check for the literal "[]" string, but string-equality checks alone are insufficient for JSON detection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/cmd/setup.go` around lines 62 - 65, The current check treating psOut==""
is insufficient because docker.ComposeExecOutput returns "[]" when no containers
exist; update the logic around docker.ComposeExecOutput (psOut variable) in the
setup flow to detect an empty container list by parsing the JSON (reuse
diagnostics.parseContainerDetails or json.Unmarshal into a slice) and treat an
empty slice as no containers, or at minimum check for the literal "[]" string;
then return the same error when no containers are found.


// Reset the setup_complete flag via the settings API.
out.Step("Resetting setup flag...")
if err := resetSetupFlag(ctx, state); err != nil {
return fmt.Errorf("resetting setup flag: %w", err)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
out.Success("Setup flag reset")

// Open browser to the setup page.
setupURL := fmt.Sprintf("http://localhost:%d/setup", state.WebPort)
out.Step(fmt.Sprintf("Opening %s", setupURL))
if err := openBrowser(ctx, setupURL); err != nil {
errOut.Warn(fmt.Sprintf("Could not open browser: %v", err))
errOut.Hint(fmt.Sprintf("Open %s manually in your browser.", setupURL))
}

return nil
}

// resetSetupFlag calls DELETE /api/v1/settings/api/setup_complete to reset
// the first-run flag so the setup wizard re-appears.
func resetSetupFlag(ctx context.Context, state config.State) error {
apiURL := fmt.Sprintf("http://localhost:%d/api/v1/settings/api/setup_complete", state.BackendPort)

ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodDelete, apiURL, nil)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+buildLocalJWT(state.JWTSecret))

resp, err := setupClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024))
_ = resp.Body.Close()
}()

if resp.StatusCode >= 400 {
return fmt.Errorf("API returned status %d", resp.StatusCode)
}
return nil
}

// openBrowser opens a URL in the default browser. Only localhost HTTP(S)
// URLs are permitted to prevent arbitrary command execution.
func openBrowser(ctx context.Context, rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL %q: %w", rawURL, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("refusing to open URL with scheme %q -- only http and https are allowed", parsed.Scheme)
}
host := parsed.Hostname()
if host != "localhost" && host != "127.0.0.1" {
return fmt.Errorf("refusing to open URL with host %q -- only localhost and 127.0.0.1 are allowed", host)
}

var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", rawURL)
case "darwin":
cmd = exec.CommandContext(ctx, "open", rawURL)
default:
cmd = exec.CommandContext(ctx, "xdg-open", rawURL)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("starting browser: %w", err)
}
go func() { _ = cmd.Wait() }() // reap child, prevent zombie
return nil
}
3 changes: 2 additions & 1 deletion docs/design/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -974,7 +974,7 @@ future CLI tool are thin clients that call the API -- they contain no business l
from GitHub Releases with automatic re-exec, compose template refresh with diff
approval, container image update with version matching), `doctor`
(diagnostics + bug report URL), `uninstall`, `version`, `config`, `completion-install`,
`backup` (create/list/restore via backend API).
`backup` (create/list/restore via backend API), `setup` (re-open first-run wizard).
Built with Cobra + charmbracelet/huh. Distributed via GoReleaser + install scripts
(`curl | sh` for Linux/macOS, `irm | iex` for Windows).

Expand All @@ -998,6 +998,7 @@ future CLI tool are thin clients that call the API -- they contain no business l
| `/api/v1/analytics` | Performance metrics, dashboards |
| `/api/v1/settings` | Runtime-editable configuration (9 namespaces), schema discovery |
| `GET /api/v1/providers`, `POST /api/v1/providers`, `PUT /api/v1/providers/{name}`, `DELETE /api/v1/providers/{name}`, `POST /api/v1/providers/{name}/test`, `GET /api/v1/providers/presets`, `POST /api/v1/providers/from-preset` | Provider CRUD, connection testing, presets, 4 auth types (api_key, oauth, custom_header, none) |
| `/api/v1/setup` | First-run setup wizard: status check (public), template listing, company/agent creation, completion gate |
| `/api/v1/admin/backups` | Manual backup, list, detail, delete |
| `/api/v1/ws` | WebSocket for real-time updates (ticket auth via `?ticket=`) |
| `POST /api/v1/auth/ws-ticket` | Exchange JWT for one-time WebSocket connection ticket |
Expand Down
21 changes: 8 additions & 13 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ synthorg status # Show container health and versions

The web dashboard is at [http://localhost:3000](http://localhost:3000) (default port).

Other CLI commands: `synthorg stop`, `synthorg logs`, `synthorg update`, `synthorg doctor`, `synthorg uninstall`, `synthorg backup`. When updating, the CLI re-launches itself after binary replacement so the remaining steps (compose refresh, image pull) use the new version. If the compose template has changed (new environment variables, hardening tweaks), the diff is shown for approval before applying.
Other CLI commands: `synthorg stop`, `synthorg logs`, `synthorg update`, `synthorg doctor`, `synthorg uninstall`, `synthorg backup`, `synthorg setup`. When updating, the CLI re-launches itself after binary replacement so the remaining steps (compose refresh, image pull) use the new version. If the compose template has changed (new environment variables, hardening tweaks), the diff is shown for approval before applying.

## Quick Start (Docker Compose — manual)

Expand Down Expand Up @@ -61,21 +61,16 @@ Configuration is in `docker/.env` (copy from `docker/.env.example`):

### First-Run Setup

After the containers are running:
After the containers are running, open the web dashboard at [http://localhost:3000](http://localhost:3000). On a fresh install, the **setup wizard** will appear automatically and guide you through:

1. **Create an admin account** by sending a POST request to the setup endpoint:
1. **Create an admin account** -- set up the first admin (CEO) user.
2. **Configure an LLM provider** -- select a preset (Ollama, OpenRouter, etc.) or add a custom provider. Test the connection inline.
3. **Create your company** -- name your synthetic organization and optionally start from a template.
4. **Hire your first agent** -- choose a role, model, and personality for the first AI agent.

```bash
curl -X POST http://localhost:8000/api/v1/auth/setup \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "your-secure-password"}'
```
After completing the wizard, the dashboard appears and the setup wizard is not shown again.

2. **Access the dashboard** at [http://localhost:3000](http://localhost:3000) and log in with your admin credentials.

3. **Verify health** with `curl http://localhost:8000/api/v1/health`.

Organization setup (choosing templates, configuring agents) is done via the dashboard. Custom template editing through the UI is planned for a future release.
To re-run the wizard later, use `synthorg setup` (resets the flag and opens the browser) or delete the `api.setup_complete` setting via the settings API.

!!! info "Active Development"
SynthOrg is under active development. The web dashboard is available for monitoring and managing the organization. Templates and some features described here may evolve. Check the [GitHub repository](https://github.com/Aureliolo/synthorg) for current status.
Expand Down
8 changes: 7 additions & 1 deletion src/synthorg/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@ def _build_middleware(api_config: ApiConfig) -> list[Middleware]:
exclude=rl_exclude,
)
auth = api_config.auth
setup_status_path = f"^{prefix}/setup/status$"
exclude_paths = (
auth.exclude_paths
if auth.exclude_paths is not None
Expand All @@ -653,9 +654,14 @@ def _build_middleware(api_config: ApiConfig) -> list[Middleware]:
"^/api$",
f"^{prefix}/auth/setup$",
f"^{prefix}/auth/login$",
setup_status_path,
)
)
# Always ensure the WS upgrade path is excluded — the WS handler
# Always ensure the setup status endpoint is publicly accessible
# even when custom exclude_paths are provided via config.
if setup_status_path not in exclude_paths:
exclude_paths = (*exclude_paths, setup_status_path)
# Always ensure the WS upgrade path is excluded -- the WS handler
# performs its own ticket-based auth, so the JWT middleware must
# not run on the upgrade request.
if ws_path not in exclude_paths:
Expand Down
3 changes: 3 additions & 0 deletions src/synthorg/api/controllers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from synthorg.api.controllers.projects import ProjectController
from synthorg.api.controllers.providers import ProviderController
from synthorg.api.controllers.settings import SettingsController
from synthorg.api.controllers.setup import SetupController
from synthorg.api.controllers.tasks import TaskController
from synthorg.api.controllers.ws import ws_handler

Expand All @@ -42,6 +43,7 @@
CollaborationController,
CoordinationController,
SettingsController,
SetupController,
BackupController,
)

Expand All @@ -66,6 +68,7 @@
"ProjectController",
"ProviderController",
"SettingsController",
"SetupController",
"TaskController",
"ws_handler",
]
Loading
Loading