feat(install): redesign web wizard and scope dev token to PAT installs

Redesign the install wizard for clarity:
- swap the sidebar layout for a centered column and a horizontal stepper
- make completed/current stepper entries clickable links
- reorder steps so Server URL precedes LLMs
- use env-var placeholders (ANTHROPIC_API_KEY, etc.) with
  per-provider "Where do I get this?" disclosures
- replace the readonly "Validated username" input with a success pill
- drop the GitHub App name field (GitHub confirms the name anyway)
- re-label the GitHub App option and split review rows by strategy
- add a copy action to the Server URL on the review screen

Scope the dev token to PAT installs:
- only generate the dev token, write its files, and set FABRO_DEV_TOKEN
  inside the GithubInstallState::Token arm
- mark dev_token optional on InstallFinishResponse in the OpenAPI spec
- hide the Development token card on /install/finishing when absent
- add app_install_finish_omits_dev_token_and_does_not_write_it test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-19 16:11:02 -04:00
parent b849738a5b
commit 54ddaa2cee
No known key found for this signature in database
10 changed files with 2947 additions and 2398 deletions

File diff suppressed because it is too large Load diff

View file

@ -2,16 +2,28 @@ export const INSTALL_PROVIDERS = [
{
id: "anthropic",
label: "Anthropic",
hint: "Claude API key.",
envVar: "ANTHROPIC_API_KEY",
keyHelp: {
url: "https://console.anthropic.com/settings/keys",
text: "Create one in the Anthropic Console under Settings → API keys.",
},
},
{
id: "openai",
label: "OpenAI",
hint: "Responses API key.",
envVar: "OPENAI_API_KEY",
keyHelp: {
url: "https://platform.openai.com/api-keys",
text: "Create one in the OpenAI platform under API keys.",
},
},
{
id: "gemini",
label: "Gemini",
hint: "Google AI Studio API key.",
envVar: "GEMINI_API_KEY",
keyHelp: {
url: "https://aistudio.google.com/apikey",
text: "Create one in Google AI Studio.",
},
},
] as const;

View file

@ -2170,7 +2170,6 @@ components:
required:
- status
- restart_url
- dev_token
properties:
status:
type: string
@ -2180,6 +2179,10 @@ components:
format: uri
dev_token:
type: string
description: |
Dev token used to bootstrap login. Only included when the operator
chose the personal access token flow; GitHub App installs rely on
OAuth and do not receive a dev token.
# ── Pagination ───────────────────────────────────────────────────────

View file

@ -815,6 +815,7 @@ async fn post_install_finish(
}
let mut server_env_secrets = Vec::new();
let mut dev_token: Option<String> = None;
match github {
GithubInstallState::Token(github) => {
if let Err(err) = write_token_settings(&mut settings_doc) {
@ -826,6 +827,25 @@ async fn post_install_finish(
secret_type: VaultSecretType::Environment,
description: None,
});
let home = state.home.clone().unwrap_or_else(Home::from_env);
let token = match dev_token::load_or_create_dev_token(&home.dev_token_path()) {
Ok(value) => value,
Err(err) => {
return install_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
);
}
};
if let Err(err) = dev_token::write_dev_token(
&Storage::new(state.storage_dir.as_ref())
.server_state()
.dev_token_path(),
&token,
) {
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
dev_token = Some(token);
}
GithubInstallState::App(github) => {
if let Err(err) = write_github_app_settings(
@ -862,22 +882,6 @@ async fn post_install_finish(
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
};
let home = state.home.clone().unwrap_or_else(Home::from_env);
let dev_token = match dev_token::load_or_create_dev_token(&home.dev_token_path()) {
Ok(value) => value,
Err(err) => {
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
};
if let Err(err) = dev_token::write_dev_token(
&Storage::new(state.storage_dir.as_ref())
.server_state()
.dev_token_path(),
&dev_token,
) {
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
server_env_secrets.extend([
(
"FABRO_JWT_PRIVATE_KEY".to_string(),
@ -888,8 +892,10 @@ async fn post_install_finish(
BASE64_STANDARD.encode(jwt_public_pem.as_bytes()),
),
("SESSION_SECRET".to_string(), session_secret),
("FABRO_DEV_TOKEN".to_string(), dev_token.clone()),
]);
if let Some(token) = dev_token.as_ref() {
server_env_secrets.push(("FABRO_DEV_TOKEN".to_string(), token.clone()));
}
let previous_settings = std::fs::read_to_string(state.config_path.as_ref()).ok();
@ -944,15 +950,14 @@ async fn post_install_finish(
}
finish_guard.disarm();
(
StatusCode::ACCEPTED,
Json(serde_json::json!({
"status": "completing",
"restart_url": server.canonical_url,
"dev_token": dev_token,
})),
)
.into_response()
let mut body = serde_json::json!({
"status": "completing",
"restart_url": server.canonical_url,
});
if let Some(token) = dev_token {
body["dev_token"] = serde_json::Value::String(token);
}
(StatusCode::ACCEPTED, Json(body)).into_response()
}
async fn render_install_shell(headers: HeaderMap, uri: OriginalUri) -> Response {

View file

@ -381,6 +381,150 @@ async fn token_install_finish_persists_settings_env_and_vault() {
assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
}
#[tokio::test]
async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
let temp_dir = tempfile::tempdir().unwrap();
let home_root = tempfile::tempdir().unwrap();
let home = Home::new(home_root.path().join(".fabro"));
let config_path = temp_dir.path().join("settings.toml");
let github_mock = MockServer::start_async().await;
github_mock
.mock_async(|when, then| {
when.method("POST")
.path("/app-manifests/stub-code/conversions");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"id": 42,
"slug": "fabro-test-app",
"client_id": "Iv1.test-client-id",
"client_secret": "test-client-secret",
"webhook_secret": "test-webhook-secret",
"pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
}"#,
);
})
.await;
let app = build_install_router(
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
.with_home(home.clone())
.with_github_api_base_url(github_mock.url("")),
);
let llm_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/llm")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(llm_response.status(), StatusCode::NO_CONTENT);
let server_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/install/server")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"canonical_url":"https://fabro.example.com"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(server_response.status(), StatusCode::NO_CONTENT);
let manifest_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/install/github/app/manifest")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(manifest_response.status(), StatusCode::OK);
let manifest_body = body_json(manifest_response.into_body()).await;
let redirect_url = manifest_body["manifest"]["redirect_url"]
.as_str()
.expect("redirect_url should be present");
let redirect_uri = fabro_http::Url::parse(redirect_url).unwrap();
let state = redirect_uri
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.expect("state should be embedded in redirect_url");
let callback_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(callback_response.status(), StatusCode::FOUND);
let finish_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/finish")
.header("authorization", "Bearer test-install-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(finish_response.status(), StatusCode::ACCEPTED);
let finish_body = body_json(finish_response.into_body()).await;
assert_eq!(finish_body["status"], "completing");
assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
assert!(
finish_body.get("dev_token").is_none(),
"App installs must not expose a dev token"
);
let server_env =
std::fs::read_to_string(Storage::new(temp_dir.path()).server_state().env_path()).unwrap();
assert!(!server_env.contains("FABRO_DEV_TOKEN="));
assert!(
!home.dev_token_path().exists(),
"home dev token file should not be created for App installs"
);
assert!(
!Storage::new(temp_dir.path())
.server_state()
.dev_token_path()
.exists(),
"storage dev token file should not be created for App installs"
);
}
#[tokio::test]
async fn token_install_finish_invokes_shutdown_callback_after_accepting() {
let temp_dir = tempfile::tempdir().unwrap();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -61,7 +61,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-70cq40fr.js"></script>
<script type="module" src="/assets/entry-rzgjexka.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>

View file

@ -20,7 +20,10 @@
export interface InstallFinishResponse {
'status': InstallFinishResponseStatusEnum;
'restart_url': string;
'dev_token': string;
/**
* Dev token used to bootstrap login. Only included when the operator chose the personal access token flow; GitHub App installs rely on OAuth and do not receive a dev token.
*/
'dev_token'?: string;
}
export const InstallFinishResponseStatusEnum = {