feat(web): rename /install → /setup; nav label 'Setup local agent'

- Add GET /setup serving install.html (CLI + Claude Code setup page)
- Add GET /install → 301 redirect to /setup for backwards compat
- Move first-time setup wizard from /setup to /first-time-setup
- Update nav link: href=/setup, label 'Setup local agent', active on both /setup and /install paths
- Update page <title> to 'Setup local agent — …'
- Update /dashboard and /setup comment in _claude_setup_instructions.jinja
- Update tests and OpenAPI snapshot accordingly
This commit is contained in:
ZdenekSrotyr 2026-05-02 19:58:20 +02:00
parent 92fd78cfb4
commit 85967e14ca
10 changed files with 532 additions and 54 deletions

View file

@ -120,7 +120,7 @@ _URL_MAP = {
"email_auth.login_email_form": "/login/email", "email_auth.login_email_form": "/login/email",
"email_auth.send_magic_link": "/auth/email/send-link", "email_auth.send_magic_link": "/auth/email/send-link",
"register": "/auth/password/setup", "register": "/auth/password/setup",
"setup": "/setup", "setup": "/first-time-setup",
} }
@ -322,9 +322,9 @@ async def index(request: Request, user: Optional[dict] = Depends(get_optional_us
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", status_code=302)
@router.get("/setup", response_class=HTMLResponse) @router.get("/first-time-setup", response_class=HTMLResponse)
async def setup_wizard(request: Request, conn: duckdb.DuckDBPyConnection = Depends(_get_db)): async def setup_wizard(request: Request, conn: duckdb.DuckDBPyConnection = Depends(_get_db)):
"""First-time setup wizard. Redirects to dashboard if users already exist.""" """First-time setup wizard. Redirects to login if users already exist."""
try: try:
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if user_count > 0: if user_count > 0:
@ -720,13 +720,13 @@ async def activity_center(
return templates.TemplateResponse(request, "activity_center.html", ctx) return templates.TemplateResponse(request, "activity_center.html", ctx)
@router.get("/install", response_class=HTMLResponse) @router.get("/setup", response_class=HTMLResponse)
async def install_page( async def setup_page(
request: Request, request: Request,
user: Optional[dict] = Depends(get_optional_user), user: Optional[dict] = Depends(get_optional_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db), conn: duckdb.DuckDBPyConnection = Depends(_get_db),
): ):
"""Public install instructions for the CLI.""" """Setup instructions for the local agent (CLI + Claude Code)."""
base_url = str(request.base_url).rstrip("/") base_url = str(request.base_url).rstrip("/")
ctx = _build_context( ctx = _build_context(
request, request,
@ -738,6 +738,12 @@ async def install_page(
return templates.TemplateResponse(request, "install.html", ctx) return templates.TemplateResponse(request, "install.html", ctx)
@router.get("/install", response_class=HTMLResponse)
async def install_redirect(request: Request):
"""Backwards-compat redirect: /install → /setup (301)."""
return RedirectResponse(url="/setup", status_code=301)
@router.get("/admin/tables", response_class=HTMLResponse) @router.get("/admin/tables", response_class=HTMLResponse)
async def admin_tables( async def admin_tables(
request: Request, request: Request,

View file

@ -11,7 +11,7 @@
<div class="app-header-right"> <div class="app-header-right">
{% set _path = request.url.path %} {% set _path = request.url.path %}
<a class="app-nav-link {% if _path == '/dashboard' or _path == '/' %}is-active{% endif %}" href="/dashboard">Dashboard</a> <a class="app-nav-link {% if _path == '/dashboard' or _path == '/' %}is-active{% endif %}" href="/dashboard">Dashboard</a>
<a class="app-nav-link {% if _path.startswith('/install') %}is-active{% endif %}" href="/install">Install CLI</a> <a class="app-nav-link {% if _path.startswith('/setup') or _path.startswith('/install') %}is-active{% endif %}" href="/setup">Setup local agent</a>
{% if session.user.is_admin %} {% if session.user.is_admin %}
<a class="app-nav-link {% if _path.startswith('/admin/marketplaces') %}is-active{% endif %}" href="/admin/marketplaces">Marketplaces</a> <a class="app-nav-link {% if _path.startswith('/admin/marketplaces') %}is-active{% endif %}" href="/admin/marketplaces">Marketplaces</a>
{% set _admin_active = _path.startswith('/admin/tables') or _path.startswith('/admin/tokens') or _path.startswith('/admin/users') or _path.startswith('/admin/groups') or _path.startswith('/admin/access') or _path.startswith('/admin/server-config') or _path.startswith('/admin/welcome') %} {% set _admin_active = _path.startswith('/admin/tables') or _path.startswith('/admin/tokens') or _path.startswith('/admin/users') or _path.startswith('/admin/groups') or _path.startswith('/admin/access') or _path.startswith('/admin/server-config') or _path.startswith('/admin/welcome') %}

View file

@ -5,7 +5,7 @@
* preview_mode=True → emits a read-only HTML <pre><code> block rendered * preview_mode=True → emits a read-only HTML <pre><code> block rendered
with the real server_url and a visible placeholder with the real server_url and a visible placeholder
for the token. Used inline on /dashboard and for the token. Used inline on /dashboard and
/install so the reader can see exactly what will /setup so the reader can see exactly what will
land in their clipboard. land in their clipboard.
* preview_mode=False → emits the JS `SETUP_INSTRUCTIONS_TEMPLATE` array + * preview_mode=False → emits the JS `SETUP_INSTRUCTIONS_TEMPLATE` array +
`renderSetupInstructions(server, token)` function. `renderSetupInstructions(server, token)` function.

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Install CLI — {{ config.INSTANCE_NAME }}</title> <title>Setup local agent — {{ config.INSTANCE_NAME }}</title>
{% if not config.THEME_FONT_URL %} {% if not config.THEME_FONT_URL %}
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View file

@ -1893,6 +1893,8 @@
}, },
"profile_after_sync": { "profile_after_sync": {
"default": true, "default": true,
"deprecated": true,
"description": "DEPRECATED: not consumed by the runtime (Agent 1 finding 2026-05-01). Profiler runs unconditionally on every synced table; this flag has no effect. Field stays for back-compat.",
"title": "Profile After Sync", "title": "Profile After Sync",
"type": "boolean" "type": "boolean"
}, },
@ -1901,6 +1903,17 @@
"title": "Query Mode", "title": "Query Mode",
"type": "string" "type": "string"
}, },
"source_query": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Query"
},
"source_table": { "source_table": {
"anyOf": [ "anyOf": [
{ {
@ -1936,6 +1949,8 @@
}, },
"sync_strategy": { "sync_strategy": {
"default": "full_refresh", "default": "full_refresh",
"deprecated": true,
"description": "DEPRECATED: catalog/profiler metadata only. No extractor reads this field; every sync is a full overwrite regardless of value. profiler.is_partitioned() consumes it for parquet-layout detection. Field stays for back-compat; will be removed in a future major release.",
"title": "Sync Strategy", "title": "Sync Strategy",
"type": "string" "type": "string"
} }
@ -2067,6 +2082,83 @@
"title": "TableSubscriptionUpdate", "title": "TableSubscriptionUpdate",
"type": "object" "type": "object"
}, },
"TemplateGetResponse": {
"properties": {
"content": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Content"
},
"default": {
"title": "Default",
"type": "string"
},
"updated_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Updated At"
},
"updated_by": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Updated By"
}
},
"required": [
"content",
"default"
],
"title": "TemplateGetResponse",
"type": "object"
},
"TemplatePreviewRequest": {
"properties": {
"content": {
"maxLength": 200000,
"minLength": 1,
"title": "Content",
"type": "string"
}
},
"required": [
"content"
],
"title": "TemplatePreviewRequest",
"type": "object"
},
"TemplatePutRequest": {
"properties": {
"content": {
"maxLength": 200000,
"minLength": 1,
"title": "Content",
"type": "string"
}
},
"required": [
"content"
],
"title": "TemplatePutRequest",
"type": "object"
},
"TokenListItem": { "TokenListItem": {
"properties": { "properties": {
"created_at": { "created_at": {
@ -2329,6 +2421,8 @@
"type": "null" "type": "null"
} }
], ],
"deprecated": true,
"description": "DEPRECATED: not consumed by the runtime. See RegisterTableRequest.profile_after_sync.",
"title": "Profile After Sync" "title": "Profile After Sync"
}, },
"query_mode": { "query_mode": {
@ -2342,6 +2436,17 @@
], ],
"title": "Query Mode" "title": "Query Mode"
}, },
"source_query": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Query"
},
"source_table": { "source_table": {
"anyOf": [ "anyOf": [
{ {
@ -2384,6 +2489,8 @@
"type": "null" "type": "null"
} }
], ],
"deprecated": true,
"description": "DEPRECATED: catalog/profiler metadata only. See RegisterTableRequest.sync_strategy.",
"title": "Sync Strategy" "title": "Sync Strategy"
} }
}, },
@ -2641,13 +2748,26 @@
], ],
"title": "VoteRequest", "title": "VoteRequest",
"type": "object" "type": "object"
},
"WelcomeResponse": {
"properties": {
"content": {
"title": "Content",
"type": "string"
}
},
"required": [
"content"
],
"title": "WelcomeResponse",
"type": "object"
} }
} }
}, },
"info": { "info": {
"description": "Data distribution platform for AI analytical systems", "description": "Data distribution platform for AI analytical systems",
"title": "AI Data Analyst", "title": "AI Data Analyst",
"version": "2.1.0" "version": "2.0.0"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@ -3238,6 +3358,55 @@
] ]
} }
}, },
"/admin/welcome": {
"get": {
"operationId": "admin_welcome_page_admin_welcome_get",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Welcome Page",
"tags": [
"web"
]
}
},
"/api/admin/access-overview": { "/api/admin/access-overview": {
"get": { "get": {
"description": "One-shot snapshot for the /admin/access page.\n\nReturns:\n - ``groups``: every user_group with member + grant counts\n - ``grants``: every (group_id, resource_type, resource_id) row\n - ``resources``: per-resource-type hierarchical layout, where each\n type has a list of *blocks* (parent entities, e.g. a marketplace)\n and each block has *items* (concrete grantable resources).\n\nUI stitches the three pieces into the two-column layout: groups on\nthe left, resources tree on the right with per-item checkboxes whose\nstate derives from ``grants``.", "description": "One-shot snapshot for the /admin/access page.\n\nReturns:\n - ``groups``: every user_group with member + grant counts\n - ``grants``: every (group_id, resource_type, resource_id) row\n - ``resources``: per-resource-type hierarchical layout, where each\n type has a list of *blocks* (parent entities, e.g. a marketplace)\n and each block has *items* (concrete grantable resources).\n\nUI stitches the three pieces into the two-column layout: groups on\nthe left, resources tree on the right with per-item checkboxes whose\nstate derives from ``grants``.",
@ -3398,9 +3567,25 @@
}, },
"/api/admin/discover-tables": { "/api/admin/discover-tables": {
"get": { "get": {
"description": "Discover all available tables from the configured data source.", "description": "Discover available tables from the configured data source.\n\nFor ``data_source.type='keboola'`` returns the full Storage API table\nlist (single round-trip). For ``data_source.type='bigquery'``:\n\n- Without ``dataset``: list datasets in the configured project.\n- With ``dataset=name``: list tables (BASE TABLE + VIEW) in that dataset.\n\nTwo-step shape avoids paying the per-dataset list_tables cost up-front\non projects with hundreds of datasets \u2014 the UI populates the dataset\ndropdown first, then fetches tables only for the selected dataset.",
"operationId": "discover_tables_api_admin_discover_tables_get", "operationId": "discover_tables_api_admin_discover_tables_get",
"parameters": [ "parameters": [
{
"in": "query",
"name": "dataset",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Dataset"
}
},
{ {
"in": "header", "in": "header",
"name": "authorization", "name": "authorization",
@ -4591,7 +4776,7 @@
}, },
"/api/admin/registry": { "/api/admin/registry": {
"get": { "get": {
"description": "Get full table registry.", "description": "Get full table registry.\n\nEach table row is enriched with `last_sync_error` from sync_state so\noperators can see WHY a row isn't materializing without trawling\nscheduler logs. None for rows that have never errored or have already\nrecovered (status='ok'); the per-row error message string otherwise.",
"operationId": "list_registry_api_admin_registry_get", "operationId": "list_registry_api_admin_registry_get",
"parameters": [ "parameters": [
{ {
@ -4639,7 +4824,7 @@
}, },
"/api/admin/registry/{table_id}": { "/api/admin/registry/{table_id}": {
"delete": { "delete": {
"description": "Unregister a table from the system.\n\nFor BQ rows, schedules a background rebuild so the dropped row's\nmaster view is removed from analytics.duckdb (rather than hanging\naround until the next scheduled sync).", "description": "Unregister a table from the system.\n\nFor BQ rows, schedules a background rebuild so the dropped row's\nmaster view is removed from analytics.duckdb (rather than hanging\naround until the next scheduled sync).\n\nFor materialized rows, also removes the canonical parquet at\n`${DATA_DIR}/extracts/<source_type>/data/<id>.parquet` and clears\nthe matching `sync_state` row. Without these two cleanups, the\nmanifest endpoint kept advertising the dropped table to `da sync`\n(sync_state-driven) and the orchestrator's next rebuild could\nresurrect a master view from the leftover parquet (E2E sub-agent\nfinding 2026-05-01).",
"operationId": "unregister_table_api_admin_registry__table_id__delete", "operationId": "unregister_table_api_admin_registry__table_id__delete",
"parameters": [ "parameters": [
{ {
@ -5163,6 +5348,210 @@
] ]
} }
}, },
"/api/admin/welcome-template": {
"delete": {
"operationId": "admin_reset_template_api_admin_welcome_template_delete",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Reset Template",
"tags": [
"welcome"
]
},
"get": {
"operationId": "admin_get_template_api_admin_welcome_template_get",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplateGetResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Get Template",
"tags": [
"welcome"
]
},
"put": {
"operationId": "admin_put_template_api_admin_welcome_template_put",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplatePutRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Put Template",
"tags": [
"welcome"
]
}
},
"/api/admin/welcome-template/preview": {
"post": {
"description": "Render arbitrary template content against the live context for the\ncalling admin, without persisting. Used by the /admin/welcome editor's\nPreview button so admins can see their edits before saving.",
"operationId": "admin_preview_template_api_admin_welcome_template_preview_post",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplatePreviewRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WelcomeResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Preview Template",
"tags": [
"welcome"
]
}
},
"/api/catalog/metrics/{metric_path}": { "/api/catalog/metrics/{metric_path}": {
"get": { "get": {
"deprecated": true, "deprecated": true,
@ -9900,6 +10289,67 @@
] ]
} }
}, },
"/api/welcome": {
"get": {
"description": "Render the welcome prompt for the calling user. Returns rendered markdown.",
"operationId": "get_welcome_api_welcome_get",
"parameters": [
{
"description": "The server URL the analyst is bootstrapping against",
"in": "query",
"name": "server_url",
"required": true,
"schema": {
"description": "The server URL the analyst is bootstrapping against",
"title": "Server Url",
"type": "string"
}
},
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WelcomeResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Welcome",
"tags": [
"welcome"
]
}
},
"/auth/admin/tokens": { "/auth/admin/tokens": {
"get": { "get": {
"operationId": "admin_list_tokens_auth_admin_tokens_get", "operationId": "admin_list_tokens_auth_admin_tokens_get",
@ -11153,28 +11603,10 @@
] ]
} }
}, },
"/install": { "/first-time-setup": {
"get": { "get": {
"description": "Public install instructions for the CLI.", "description": "First-time setup wizard. Redirects to login if users already exist.",
"operationId": "install_page_install_get", "operationId": "setup_wizard_first_time_setup_get",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@ -11185,19 +11617,31 @@
} }
}, },
"description": "Successful Response" "description": "Successful Response"
}
}, },
"422": { "summary": "Setup Wizard",
"tags": [
"web"
]
}
},
"/install": {
"get": {
"description": "Backwards-compat redirect: /install \u2192 /setup (301).",
"operationId": "install_redirect_install_get",
"responses": {
"200": {
"content": { "content": {
"application/json": { "text/html": {
"schema": { "schema": {
"$ref": "#/components/schemas/HTTPValidationError" "type": "string"
} }
} }
}, },
"description": "Validation Error" "description": "Successful Response"
} }
}, },
"summary": "Install Page", "summary": "Install Redirect",
"tags": [ "tags": [
"web" "web"
] ]
@ -11511,8 +11955,26 @@
}, },
"/setup": { "/setup": {
"get": { "get": {
"description": "First-time setup wizard. Redirects to dashboard if users already exist.", "description": "Setup instructions for the local agent (CLI + Claude Code).",
"operationId": "setup_wizard_setup_get", "operationId": "setup_page_setup_get",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@ -11523,9 +11985,19 @@
} }
}, },
"description": "Successful Response" "description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
} }
}, },
"summary": "Setup Wizard", "description": "Validation Error"
}
},
"summary": "Setup Page",
"tags": [ "tags": [
"web" "web"
] ]

View file

@ -100,7 +100,7 @@ def test_install_page_renders_with_server_url(tmp_path, monkeypatch):
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
client = TestClient(app) client = TestClient(app)
resp = client.get("/install", headers={"host": "agnes.test", "Accept": "text/html"}) resp = client.get("/setup", headers={"host": "agnes.test", "Accept": "text/html"})
assert resp.status_code == 200 assert resp.status_code == 200
assert "agnes.test" in resp.text assert "agnes.test" in resp.text
assert "da auth whoami" in resp.text assert "da auth whoami" in resp.text

View file

@ -41,20 +41,20 @@ def test_parquet_path_is_not_gzipped(isolated_client, tmp_path, monkeypatch):
def test_install_page_is_gzipped(isolated_client): def test_install_page_is_gzipped(isolated_client):
"""/install is HTML above the threshold — gzip should kick in when the """/setup is HTML above the threshold — gzip should kick in when the
client advertises gzip support. TestClient may decompress transparently, client advertises gzip support. TestClient may decompress transparently,
so we accept either the header or readable body as proof that the so we accept either the header or readable body as proof that the
middleware decided to handle the response (i.e. did not skip).""" middleware decided to handle the response (i.e. did not skip)."""
resp = isolated_client.get("/install", headers={"Accept-Encoding": "gzip"}) resp = isolated_client.get("/setup", headers={"Accept-Encoding": "gzip"})
assert resp.status_code == 200 assert resp.status_code == 200
enc = resp.headers.get("content-encoding", "") enc = resp.headers.get("content-encoding", "")
# Either we see the encoding on the wire OR TestClient auto-decoded it. # Either we see the encoding on the wire OR TestClient auto-decoded it.
assert "gzip" in enc or "install" in resp.text.lower() assert "gzip" in enc or "setup" in resp.text.lower()
def test_no_accept_encoding_means_no_gzip_anywhere(isolated_client): def test_no_accept_encoding_means_no_gzip_anywhere(isolated_client):
"""Client that doesn't advertise gzip gets uncompressed body.""" """Client that doesn't advertise gzip gets uncompressed body."""
resp = isolated_client.get("/install", headers={"Accept-Encoding": "identity"}) resp = isolated_client.get("/setup", headers={"Accept-Encoding": "identity"})
assert resp.status_code == 200 assert resp.status_code == 200
assert "gzip" not in resp.headers.get("content-encoding", "") assert "gzip" not in resp.headers.get("content-encoding", "")

View file

@ -789,7 +789,7 @@ def test_install_page_uses_versioned_wheel_url(monkeypatch, tmp_path):
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
client = TestClient(app) client = TestClient(app)
resp = client.get("/install", headers={"host": "agnes.test", "Accept": "text/html"}) resp = client.get("/setup", headers={"host": "agnes.test", "Accept": "text/html"})
assert resp.status_code == 200 assert resp.status_code == 200
assert "/cli/wheel/agnes_the_ai_analyst-2.0.0-py3-none-any.whl" in resp.text assert "/cli/wheel/agnes_the_ai_analyst-2.0.0-py3-none-any.whl" in resp.text
# The bare alias must no longer appear in the rendered snippet. # The bare alias must no longer appear in the rendered snippet.

View file

@ -54,7 +54,7 @@ def app_no_toolbar(monkeypatch, tmp_path, reset_logging_state):
@pytest.mark.integration @pytest.mark.integration
def test_no_toolbar_when_debug_off(app_no_toolbar): def test_no_toolbar_when_debug_off(app_no_toolbar):
client = TestClient(app_no_toolbar) client = TestClient(app_no_toolbar)
resp = client.get("/setup", follow_redirects=False) resp = client.get("/first-time-setup", follow_redirects=False)
if resp.status_code in (302, 401): if resp.status_code in (302, 401):
# Auth redirect — toolbar wouldn't render anyway. The point of this # Auth redirect — toolbar wouldn't render anyway. The point of this
# test is to assert markup ABSENCE; no markup, no failure. # test is to assert markup ABSENCE; no markup, no failure.
@ -76,7 +76,7 @@ def test_toolbar_html_present_when_debug(app_with_toolbar):
client = TestClient(app_with_toolbar) client = TestClient(app_with_toolbar)
# Try several HTML routes — at least one should respond 200 under # Try several HTML routes — at least one should respond 200 under
# LOCAL_DEV_MODE=1 (auth bypass). # LOCAL_DEV_MODE=1 (auth bypass).
for path in ("/dashboard", "/setup", "/login", "/admin/access"): for path in ("/dashboard", "/first-time-setup", "/login", "/admin/access"):
resp = client.get(path, follow_redirects=False) resp = client.get(path, follow_redirects=False)
if resp.status_code == 200 and "text/html" in resp.headers.get("content-type", ""): if resp.status_code == 200 and "text/html" in resp.headers.get("content-type", ""):
body = resp.text.lower() body = resp.text.lower()

View file

@ -214,7 +214,7 @@ class TestClaudeSetupPreview:
""" """
def test_install_preview_visible_for_signed_in_user(self, web_client, admin_cookie): def test_install_preview_visible_for_signed_in_user(self, web_client, admin_cookie):
resp = web_client.get("/install", cookies=admin_cookie) resp = web_client.get("/setup", cookies=admin_cookie)
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.text body = resp.text
# Preview card + placeholder token render # Preview card + placeholder token render
@ -243,10 +243,10 @@ class TestClaudeSetupPreview:
assert "&lt;will be generated on click&gt;" in body assert "&lt;will be generated on click&gt;" in body
def test_install_mcp_card_removed(self, web_client): def test_install_mcp_card_removed(self, web_client):
"""The stale 'Use with Claude Code / MCP' card on /install has been """The stale 'Use with Claude Code / MCP' card on /setup has been
removed there is no Agnes MCP server today. removed there is no Agnes MCP server today.
""" """
resp = web_client.get("/install") resp = web_client.get("/setup")
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.text body = resp.text
assert "Use with Claude Code / MCP" not in body assert "Use with Claude Code / MCP" not in body