From ecdfdd82d86f947f2bbf450176bc422d15d534ff Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 11:20:58 -0400 Subject: [PATCH] feat(install): add browser-based setup flow Implement the web-first install experience across the server, CLI, API spec, web app, and packaged SPA assets. This also removes test-side process env mutation by pushing env-dependent decision points behind explicit helpers and test wiring. --- Cargo.lock | 15 + Dockerfile | 8 +- apps/fabro-web/app/entry.tsx | 12 +- apps/fabro-web/app/install-api.test.ts | 37 + apps/fabro-web/app/install-api.ts | 208 ++ apps/fabro-web/app/install-app.tsx | 1121 ++++++++++ apps/fabro-web/app/install-router.tsx | 18 + apps/fabro-web/app/mode.test.ts | 31 + apps/fabro-web/app/mode.ts | 22 + apps/fabro-web/scripts/build.ts | 1 - docker-compose.yaml | 1 + docker/entrypoint.sh | 2 + docker/settings.toml | 2 - docs/api-reference/fabro-api.yaml | 497 +++++ lib/crates/fabro-auth/src/resolve.rs | 49 +- .../fabro-cli/src/commands/server/mod.rs | 207 ++ .../fabro-cli/src/commands/server/start.rs | 54 +- lib/crates/fabro-cli/src/server_client.rs | 57 +- .../fabro-cli/tests/it/cmd/server_start.rs | 92 +- lib/crates/fabro-http/src/lib.rs | 60 +- lib/crates/fabro-install/Cargo.toml | 22 + lib/crates/fabro-install/src/lib.rs | 452 ++++ lib/crates/fabro-server/Cargo.toml | 1 + lib/crates/fabro-server/src/install.rs | 1266 +++++++++++ lib/crates/fabro-server/src/lib.rs | 1 + lib/crates/fabro-server/src/serve.rs | 1 + lib/crates/fabro-server/src/server.rs | 19 +- lib/crates/fabro-server/src/static_files.rs | 41 +- .../fabro-server/tests/it/api/install.rs | 763 +++++++ lib/crates/fabro-server/tests/it/api/mod.rs | 1 + .../fabro-server/tests/it/api/routing.rs | 18 + .../tests/it/openapi_conformance.rs | 94 +- .../fabro-server/tests/it/scenario/dry_run.rs | 33 - lib/crates/fabro-spa/assets/assets/app.css | 2 +- .../fabro-spa/assets/assets/chunk-019jygp1.js | 2 - .../fabro-spa/assets/assets/chunk-02n7dnxd.js | 2 - .../fabro-spa/assets/assets/chunk-05s0gc93.js | 2 - .../fabro-spa/assets/assets/chunk-06fm9b6d.js | 2 - .../fabro-spa/assets/assets/chunk-07en1qh1.js | 2 - .../fabro-spa/assets/assets/chunk-09p5nycp.js | 2 - .../fabro-spa/assets/assets/chunk-0dse7fse.js | 2 - .../fabro-spa/assets/assets/chunk-0nc05j52.js | 2 - .../fabro-spa/assets/assets/chunk-0pazjatd.js | 2 - .../fabro-spa/assets/assets/chunk-0pkns8zn.js | 2 - .../fabro-spa/assets/assets/chunk-0tq24xt3.js | 2 - .../fabro-spa/assets/assets/chunk-11c3h1xt.js | 2 - .../fabro-spa/assets/assets/chunk-11vzjswv.js | 2 - .../fabro-spa/assets/assets/chunk-17r63emm.js | 2 - .../fabro-spa/assets/assets/chunk-18pbarpx.js | 2 - .../fabro-spa/assets/assets/chunk-1ezq7eg5.js | 2 - .../fabro-spa/assets/assets/chunk-1jb7a4x7.js | 2 - .../fabro-spa/assets/assets/chunk-1wjv325z.js | 2 - .../fabro-spa/assets/assets/chunk-1xpescw2.js | 2 - .../fabro-spa/assets/assets/chunk-1z6jks96.js | 2 - .../fabro-spa/assets/assets/chunk-207mkg9t.js | 2 - .../fabro-spa/assets/assets/chunk-2cjhppva.js | 2 - .../fabro-spa/assets/assets/chunk-2d0xan0e.js | 2 - .../fabro-spa/assets/assets/chunk-2eh3eq47.js | 2 - .../fabro-spa/assets/assets/chunk-2ndh3q6f.js | 2 - .../fabro-spa/assets/assets/chunk-2rnptd9f.js | 2 - .../fabro-spa/assets/assets/chunk-2t67c0zp.js | 2 - .../fabro-spa/assets/assets/chunk-2v5x7cyg.js | 2 - .../fabro-spa/assets/assets/chunk-2xqt91k6.js | 2 - .../fabro-spa/assets/assets/chunk-2z02c7x4.js | 2 - .../fabro-spa/assets/assets/chunk-36rb0g8e.js | 2 - .../fabro-spa/assets/assets/chunk-3bn8ccxt.js | 2 - .../fabro-spa/assets/assets/chunk-3g84zxfd.js | 2 - .../fabro-spa/assets/assets/chunk-3m9zvayn.js | 2 - .../fabro-spa/assets/assets/chunk-3my0wjn6.js | 2 - .../fabro-spa/assets/assets/chunk-3nbmjrwz.js | 2 - .../fabro-spa/assets/assets/chunk-3qzmnqbk.js | 2 - .../fabro-spa/assets/assets/chunk-3rchmqge.js | 2 - .../fabro-spa/assets/assets/chunk-3tfe6c9b.js | 2 - .../fabro-spa/assets/assets/chunk-457408mh.js | 2 - .../fabro-spa/assets/assets/chunk-4833vrwm.js | 2 - .../fabro-spa/assets/assets/chunk-48syp4by.js | 2 - .../fabro-spa/assets/assets/chunk-4bp68sr2.js | 2 - .../fabro-spa/assets/assets/chunk-4g4hkb7x.js | 2 - .../fabro-spa/assets/assets/chunk-4h0ks3v3.js | 2 - .../fabro-spa/assets/assets/chunk-4j8a65qz.js | 2 - .../fabro-spa/assets/assets/chunk-4v5fshv2.js | 2 - .../fabro-spa/assets/assets/chunk-50s79t1q.js | 2 - .../fabro-spa/assets/assets/chunk-551b9h9v.js | 2 - .../fabro-spa/assets/assets/chunk-56hr8ze6.js | 2 - .../fabro-spa/assets/assets/chunk-56ztj47e.js | 2 - .../fabro-spa/assets/assets/chunk-5ass1cr8.js | 2 - .../fabro-spa/assets/assets/chunk-5bdrk519.js | 2 - .../fabro-spa/assets/assets/chunk-5exp5fdx.js | 2 - .../fabro-spa/assets/assets/chunk-5gqhakby.js | 2 - .../fabro-spa/assets/assets/chunk-5w2bs93r.js | 2 - .../fabro-spa/assets/assets/chunk-61ctvdht.js | 2 - .../fabro-spa/assets/assets/chunk-63fds60w.js | 2 - .../fabro-spa/assets/assets/chunk-65xe94t7.js | 2 - .../fabro-spa/assets/assets/chunk-6h2erjze.js | 2 - .../fabro-spa/assets/assets/chunk-6jf76z88.js | 2 - .../fabro-spa/assets/assets/chunk-6jy2cvk9.js | 2 - .../fabro-spa/assets/assets/chunk-6kg96dj9.js | 2 - .../fabro-spa/assets/assets/chunk-6ma2q84r.js | 2 - .../fabro-spa/assets/assets/chunk-6p6xafmt.js | 2 - .../fabro-spa/assets/assets/chunk-6v4gpbdm.js | 2 - .../fabro-spa/assets/assets/chunk-6x0cp4ft.js | 2 - .../fabro-spa/assets/assets/chunk-6xtve84b.js | 2 - .../fabro-spa/assets/assets/chunk-6z53j5sy.js | 2 - .../fabro-spa/assets/assets/chunk-72xk2942.js | 2 - .../fabro-spa/assets/assets/chunk-75fy5bdm.js | 2 - .../fabro-spa/assets/assets/chunk-78zbagrn.js | 2 - .../fabro-spa/assets/assets/chunk-79ywv1g9.js | 2 - .../fabro-spa/assets/assets/chunk-7bh2jdae.js | 2 - .../fabro-spa/assets/assets/chunk-7ehscpxq.js | 2 - .../fabro-spa/assets/assets/chunk-7f3p0xqq.js | 2 - .../fabro-spa/assets/assets/chunk-7r410k8q.js | 2 - .../fabro-spa/assets/assets/chunk-7vrbbh2r.js | 2 - .../fabro-spa/assets/assets/chunk-7wezvt4b.js | 2 - .../fabro-spa/assets/assets/chunk-87fva4et.js | 2 - .../fabro-spa/assets/assets/chunk-8c9bs2ba.js | 2 - .../fabro-spa/assets/assets/chunk-8mdqjrtg.js | 2 - .../fabro-spa/assets/assets/chunk-8mhds0wh.js | 2 - .../fabro-spa/assets/assets/chunk-8n4az5gq.js | 2 - .../fabro-spa/assets/assets/chunk-8peyj85j.js | 2 - .../fabro-spa/assets/assets/chunk-8v6fnbbx.js | 2 - .../fabro-spa/assets/assets/chunk-8xd3q6bh.js | 2 - .../fabro-spa/assets/assets/chunk-8yfxtt5d.js | 2 - .../fabro-spa/assets/assets/chunk-8z0qc74b.js | 2 - .../fabro-spa/assets/assets/chunk-8z6nk9f0.js | 2 - .../fabro-spa/assets/assets/chunk-902g9fxx.js | 2 - .../fabro-spa/assets/assets/chunk-90hhq6ga.js | 2 - .../fabro-spa/assets/assets/chunk-9101h887.js | 2 - .../fabro-spa/assets/assets/chunk-920h6h23.js | 2 - .../fabro-spa/assets/assets/chunk-96gcsfr8.js | 2 - .../fabro-spa/assets/assets/chunk-987ve6q9.js | 2 - .../fabro-spa/assets/assets/chunk-98k5hywg.js | 2 - .../fabro-spa/assets/assets/chunk-9c29ggzn.js | 2 - .../fabro-spa/assets/assets/chunk-9de8zztk.js | 2 - .../fabro-spa/assets/assets/chunk-9e1qwkc0.js | 2 - .../fabro-spa/assets/assets/chunk-9exrs9cr.js | 2 - .../fabro-spa/assets/assets/chunk-9mnmk4y2.js | 2 - .../fabro-spa/assets/assets/chunk-9t57pdty.js | 2 - .../fabro-spa/assets/assets/chunk-9t6ftmv1.js | 2 - .../fabro-spa/assets/assets/chunk-9w9wrmb8.js | 2 - .../fabro-spa/assets/assets/chunk-9ymzkh14.js | 2 - .../fabro-spa/assets/assets/chunk-9yxtr6b6.js | 2 - .../fabro-spa/assets/assets/chunk-a24qcegx.js | 2 - .../fabro-spa/assets/assets/chunk-a47kj248.js | 2 - .../fabro-spa/assets/assets/chunk-a51kkeyv.js | 2 - .../fabro-spa/assets/assets/chunk-a9g0d87v.js | 2 - .../fabro-spa/assets/assets/chunk-ab4vmcds.js | 2 - .../fabro-spa/assets/assets/chunk-acsgr2m8.js | 2 - .../fabro-spa/assets/assets/chunk-ah94fbbe.js | 2 - .../fabro-spa/assets/assets/chunk-aj9s5f9k.js | 2 - .../fabro-spa/assets/assets/chunk-akwfy8wn.js | 2 - .../fabro-spa/assets/assets/chunk-armxp3zn.js | 2 - .../fabro-spa/assets/assets/chunk-avgm78q3.js | 2 - .../fabro-spa/assets/assets/chunk-awbzcd9m.js | 2 - .../fabro-spa/assets/assets/chunk-b173jpp2.js | 2 - .../fabro-spa/assets/assets/chunk-b1sy5kac.js | 2 - .../fabro-spa/assets/assets/chunk-ba2typew.js | 2 - .../fabro-spa/assets/assets/chunk-baw4wjvc.js | 2 - .../fabro-spa/assets/assets/chunk-bbjv3mcf.js | 2 - .../fabro-spa/assets/assets/chunk-bcp47gas.js | 2 - .../fabro-spa/assets/assets/chunk-be463r1s.js | 2 - .../fabro-spa/assets/assets/chunk-bj6rb0tc.js | 2 - .../fabro-spa/assets/assets/chunk-btpdjb6n.js | 2 - .../fabro-spa/assets/assets/chunk-byx1xjhw.js | 2 - .../fabro-spa/assets/assets/chunk-c4pk66bt.js | 2 - .../fabro-spa/assets/assets/chunk-c5mjg0qf.js | 2 - .../fabro-spa/assets/assets/chunk-c6gysrv0.js | 2 - .../fabro-spa/assets/assets/chunk-c6h3n9wp.js | 2 - .../fabro-spa/assets/assets/chunk-c7dsx0a3.js | 2 - .../fabro-spa/assets/assets/chunk-c9cv6qdm.js | 2 - .../fabro-spa/assets/assets/chunk-cfknpdwe.js | 2 - .../fabro-spa/assets/assets/chunk-cfqkkm9n.js | 2 - .../fabro-spa/assets/assets/chunk-chsjnf53.js | 2 - .../fabro-spa/assets/assets/chunk-chywzwk3.js | 2 - .../fabro-spa/assets/assets/chunk-csfvbt7s.js | 2 - .../fabro-spa/assets/assets/chunk-csnb7rks.js | 2 - .../fabro-spa/assets/assets/chunk-cvdkeqcs.js | 2 - .../fabro-spa/assets/assets/chunk-cws2ks36.js | 2 - .../fabro-spa/assets/assets/chunk-cyr7gkwx.js | 2 - .../fabro-spa/assets/assets/chunk-d0p7g53p.js | 2 - .../fabro-spa/assets/assets/chunk-d2pbkwzw.js | 2 - .../fabro-spa/assets/assets/chunk-d2rnnscz.js | 2 - .../fabro-spa/assets/assets/chunk-d6xt8xhc.js | 2 - .../fabro-spa/assets/assets/chunk-d8s6b9rv.js | 2 - .../fabro-spa/assets/assets/chunk-d97t3fd8.js | 2 - .../fabro-spa/assets/assets/chunk-dd3vxa06.js | 2 - .../fabro-spa/assets/assets/chunk-dn6szkja.js | 2 - .../fabro-spa/assets/assets/chunk-dn79dghe.js | 2 - .../fabro-spa/assets/assets/chunk-dvna8fze.js | 2 - .../fabro-spa/assets/assets/chunk-e1mesqtx.js | 2 - .../fabro-spa/assets/assets/chunk-eachxzks.js | 2 - .../fabro-spa/assets/assets/chunk-eaexpy25.js | 2 - .../fabro-spa/assets/assets/chunk-ebraw617.js | 2 - .../fabro-spa/assets/assets/chunk-ee5ffmv7.js | 2 - .../fabro-spa/assets/assets/chunk-eew9p379.js | 2 - .../fabro-spa/assets/assets/chunk-ef8450p6.js | 2 - .../fabro-spa/assets/assets/chunk-em0zsd82.js | 2 - .../fabro-spa/assets/assets/chunk-erxdztbx.js | 2 - .../fabro-spa/assets/assets/chunk-esbzyb57.js | 2 - .../fabro-spa/assets/assets/chunk-etxafnd6.js | 2 - .../fabro-spa/assets/assets/chunk-f34ztz5g.js | 2 - .../fabro-spa/assets/assets/chunk-f3tyqdty.js | 2 - .../fabro-spa/assets/assets/chunk-fbwa2cfs.js | 2 - .../fabro-spa/assets/assets/chunk-fjy7665g.js | 2 - .../fabro-spa/assets/assets/chunk-fk8hckdh.js | 2 - .../fabro-spa/assets/assets/chunk-fkyxb6ae.js | 2 - .../fabro-spa/assets/assets/chunk-fvbhfjt0.js | 2 - .../fabro-spa/assets/assets/chunk-fwdy1tf5.js | 2 - .../fabro-spa/assets/assets/chunk-fyxavgx7.js | 2 - .../fabro-spa/assets/assets/chunk-fzakadrz.js | 2 - .../fabro-spa/assets/assets/chunk-g1zh8k8v.js | 2 - .../fabro-spa/assets/assets/chunk-g3527668.js | 2 - .../fabro-spa/assets/assets/chunk-ga01y5x5.js | 2 - .../fabro-spa/assets/assets/chunk-gf5c68z4.js | 2 - .../fabro-spa/assets/assets/chunk-ggpkgcfa.js | 2 - .../fabro-spa/assets/assets/chunk-ghad1e1w.js | 2 - .../fabro-spa/assets/assets/chunk-gq03p1hd.js | 2 - .../fabro-spa/assets/assets/chunk-gwjyp4yd.js | 2 - .../fabro-spa/assets/assets/chunk-h7f0amn3.js | 2 - .../fabro-spa/assets/assets/chunk-hd5d17zs.js | 2 - .../fabro-spa/assets/assets/chunk-hgjbbj4c.js | 2 - .../fabro-spa/assets/assets/chunk-hhx1aavy.js | 2 - .../fabro-spa/assets/assets/chunk-hmhkhxp2.js | 2 - .../fabro-spa/assets/assets/chunk-htahhx45.js | 2 - .../fabro-spa/assets/assets/chunk-j4nm0wg8.js | 2 - .../fabro-spa/assets/assets/chunk-j75cbz63.js | 2 - .../fabro-spa/assets/assets/chunk-jc1vw5fd.js | 2 - .../fabro-spa/assets/assets/chunk-jc9zdc67.js | 2 - .../fabro-spa/assets/assets/chunk-jemhbdxk.js | 2 - .../fabro-spa/assets/assets/chunk-jfjf0jp7.js | 2 - .../fabro-spa/assets/assets/chunk-jpbad646.js | 2 - .../fabro-spa/assets/assets/chunk-jtbngvde.js | 2 - .../fabro-spa/assets/assets/chunk-jtffthwb.js | 2 - .../fabro-spa/assets/assets/chunk-jvygjw94.js | 2 - .../fabro-spa/assets/assets/chunk-k0s3svvy.js | 2 - .../fabro-spa/assets/assets/chunk-k28qfdjw.js | 2 - .../fabro-spa/assets/assets/chunk-k5jdp2xd.js | 2 - .../fabro-spa/assets/assets/chunk-k7ynz5mz.js | 2 - .../fabro-spa/assets/assets/chunk-k8m07by6.js | 2 - .../fabro-spa/assets/assets/chunk-kaj4vs6d.js | 2 - .../fabro-spa/assets/assets/chunk-kb76fs43.js | 2 - .../fabro-spa/assets/assets/chunk-kdjgd0g9.js | 2 - .../fabro-spa/assets/assets/chunk-ke79syqy.js | 2 - .../fabro-spa/assets/assets/chunk-kgq5332v.js | 2 - .../fabro-spa/assets/assets/chunk-km5w7dec.js | 2 - .../fabro-spa/assets/assets/chunk-kqq1a30a.js | 2 - .../fabro-spa/assets/assets/chunk-kv06br00.js | 2 - .../fabro-spa/assets/assets/chunk-kxn7andw.js | 2 - .../fabro-spa/assets/assets/chunk-kxzd3ypz.js | 2 - .../fabro-spa/assets/assets/chunk-ky56e8mk.js | 2 - .../fabro-spa/assets/assets/chunk-m2sbqbtm.js | 2 - .../fabro-spa/assets/assets/chunk-m76mdd1n.js | 2 - .../fabro-spa/assets/assets/chunk-m8j14bjr.js | 2 - .../fabro-spa/assets/assets/chunk-mbdgfkdv.js | 2 - .../fabro-spa/assets/assets/chunk-mbpm8tp5.js | 2 - .../fabro-spa/assets/assets/chunk-mjacvcxt.js | 2 - .../fabro-spa/assets/assets/chunk-mkrka56j.js | 2 - .../fabro-spa/assets/assets/chunk-mmwq9sqb.js | 2 - .../fabro-spa/assets/assets/chunk-mshhqkhw.js | 2 - .../fabro-spa/assets/assets/chunk-my086dk6.js | 2 - .../fabro-spa/assets/assets/chunk-n1k68xa8.js | 2 - .../fabro-spa/assets/assets/chunk-n2yspqr0.js | 2 - .../fabro-spa/assets/assets/chunk-n3vkfzf2.js | 2 - .../fabro-spa/assets/assets/chunk-n46mdkj2.js | 2 - .../fabro-spa/assets/assets/chunk-nb7r3qa9.js | 2 - .../fabro-spa/assets/assets/chunk-ndd1s58m.js | 2 - .../fabro-spa/assets/assets/chunk-ndrfe9w3.js | 2 - .../fabro-spa/assets/assets/chunk-nk5ky6zd.js | 2 - .../fabro-spa/assets/assets/chunk-npf8ns55.js | 2 - .../fabro-spa/assets/assets/chunk-nr8rrtg9.js | 2 - .../fabro-spa/assets/assets/chunk-nsg7r6pt.js | 2 - .../fabro-spa/assets/assets/chunk-p01md5kb.js | 2 - .../fabro-spa/assets/assets/chunk-p14884cp.js | 2 - .../fabro-spa/assets/assets/chunk-p59yzxc1.js | 2 - .../fabro-spa/assets/assets/chunk-p5b70qpc.js | 2 - .../fabro-spa/assets/assets/chunk-p7fc4tsd.js | 2 - .../fabro-spa/assets/assets/chunk-pd6abytg.js | 2 - .../fabro-spa/assets/assets/chunk-pectm3zk.js | 2 - .../fabro-spa/assets/assets/chunk-phcaqr54.js | 2 - .../fabro-spa/assets/assets/chunk-pmaq1598.js | 2 - .../fabro-spa/assets/assets/chunk-pmthkscp.js | 2 - .../fabro-spa/assets/assets/chunk-pn8pe904.js | 2 - .../fabro-spa/assets/assets/chunk-pncyzxg8.js | 2 - .../fabro-spa/assets/assets/chunk-psq81tp2.js | 2 - .../fabro-spa/assets/assets/chunk-ptvkbn7g.js | 2 - .../fabro-spa/assets/assets/chunk-q07bg6gn.js | 2 - .../fabro-spa/assets/assets/chunk-q0vrryxz.js | 2 - .../fabro-spa/assets/assets/chunk-q2qv5qr4.js | 2 - .../fabro-spa/assets/assets/chunk-q39a3r2f.js | 2 - .../fabro-spa/assets/assets/chunk-q3g4jpcq.js | 2 - .../fabro-spa/assets/assets/chunk-q65fbn3b.js | 2 - .../fabro-spa/assets/assets/chunk-q6h0xf66.js | 2 - .../fabro-spa/assets/assets/chunk-q6ham3g2.js | 2 - .../fabro-spa/assets/assets/chunk-qaxg1f9d.js | 2 - .../fabro-spa/assets/assets/chunk-qb2gzhv8.js | 2 - .../fabro-spa/assets/assets/chunk-qde7gss3.js | 2 - .../fabro-spa/assets/assets/chunk-qfxxt7qs.js | 2 - .../fabro-spa/assets/assets/chunk-qnfzgtdb.js | 2 - .../fabro-spa/assets/assets/chunk-qpmc3wjq.js | 2 - .../fabro-spa/assets/assets/chunk-qrfhzdgw.js | 2 - .../fabro-spa/assets/assets/chunk-qs7etptz.js | 2 - .../fabro-spa/assets/assets/chunk-qw1h4rez.js | 2 - .../fabro-spa/assets/assets/chunk-qz4nw45n.js | 2 - .../fabro-spa/assets/assets/chunk-r2w3cygs.js | 2 - .../fabro-spa/assets/assets/chunk-r5kt8fba.js | 2 - .../fabro-spa/assets/assets/chunk-r75z3brq.js | 2 - .../fabro-spa/assets/assets/chunk-r9hd67p9.js | 2 - .../fabro-spa/assets/assets/chunk-rapg8dhf.js | 2 - .../fabro-spa/assets/assets/chunk-rbfg5snd.js | 2 - .../fabro-spa/assets/assets/chunk-rcqepjsk.js | 2 - .../fabro-spa/assets/assets/chunk-rgez69q8.js | 2 - .../fabro-spa/assets/assets/chunk-rh0e3ajf.js | 2 - .../fabro-spa/assets/assets/chunk-rsph5pvm.js | 2 - .../fabro-spa/assets/assets/chunk-sadshphz.js | 2 - .../fabro-spa/assets/assets/chunk-sbh2grtg.js | 2 - .../fabro-spa/assets/assets/chunk-sbn8gekr.js | 2 - .../fabro-spa/assets/assets/chunk-smdcgjwd.js | 2 - .../fabro-spa/assets/assets/chunk-svjx2h8c.js | 2 - .../fabro-spa/assets/assets/chunk-svns442s.js | 2 - .../fabro-spa/assets/assets/chunk-sxph8y9z.js | 2 - .../fabro-spa/assets/assets/chunk-t0m0y0zg.js | 2 - .../fabro-spa/assets/assets/chunk-t2049s6y.js | 2 - .../fabro-spa/assets/assets/chunk-t2f2nqdv.js | 2 - .../fabro-spa/assets/assets/chunk-t5bjexfm.js | 2 - .../fabro-spa/assets/assets/chunk-t8zbyt7b.js | 2 - .../fabro-spa/assets/assets/chunk-t93s8qrg.js | 2 - .../fabro-spa/assets/assets/chunk-t9d9v6n6.js | 2 - .../fabro-spa/assets/assets/chunk-tc62g36x.js | 2 - .../fabro-spa/assets/assets/chunk-tfvpfprw.js | 2 - .../fabro-spa/assets/assets/chunk-tjs01n4p.js | 2 - .../fabro-spa/assets/assets/chunk-tjzzpcck.js | 2 - .../fabro-spa/assets/assets/chunk-tk854k1k.js | 2 - .../fabro-spa/assets/assets/chunk-tkb7chq0.js | 2 - .../fabro-spa/assets/assets/chunk-tqzz87j8.js | 2 - .../fabro-spa/assets/assets/chunk-trdkyqje.js | 2 - .../fabro-spa/assets/assets/chunk-trhe0hqm.js | 2 - .../fabro-spa/assets/assets/chunk-ttnh8s6j.js | 2 - .../fabro-spa/assets/assets/chunk-tv34b2xa.js | 2 - .../fabro-spa/assets/assets/chunk-tx0vzgf6.js | 2 - .../fabro-spa/assets/assets/chunk-txw8s4xq.js | 2 - .../fabro-spa/assets/assets/chunk-v2bk3hqh.js | 2 - .../fabro-spa/assets/assets/chunk-v61ks9f7.js | 2 - .../fabro-spa/assets/assets/chunk-v9ftnkm2.js | 2 - .../fabro-spa/assets/assets/chunk-vb08mr9g.js | 2 - .../fabro-spa/assets/assets/chunk-vb7qqcne.js | 2 - .../fabro-spa/assets/assets/chunk-vc7h2mcw.js | 2 - .../fabro-spa/assets/assets/chunk-vjx23yxz.js | 2 - .../fabro-spa/assets/assets/chunk-vkqn43a3.js | 2 - .../fabro-spa/assets/assets/chunk-vnxhvf32.js | 2 - .../fabro-spa/assets/assets/chunk-vyvre5f7.js | 2 - .../fabro-spa/assets/assets/chunk-vz6zae36.js | 2 - .../fabro-spa/assets/assets/chunk-vznmwsj2.js | 2 - .../fabro-spa/assets/assets/chunk-w0p583k6.js | 2 - .../fabro-spa/assets/assets/chunk-w24seer2.js | 2 - .../fabro-spa/assets/assets/chunk-w4txx8sc.js | 2 - .../fabro-spa/assets/assets/chunk-wk8q2ejk.js | 2 - .../fabro-spa/assets/assets/chunk-wq7vfert.js | 2 - .../fabro-spa/assets/assets/chunk-wv916p92.js | 2 - .../fabro-spa/assets/assets/chunk-wx1qv58v.js | 2 - .../fabro-spa/assets/assets/chunk-wyhemkn7.js | 2 - .../fabro-spa/assets/assets/chunk-wzb9zsp9.js | 2 - .../fabro-spa/assets/assets/chunk-x4p58zc4.js | 2 - .../fabro-spa/assets/assets/chunk-xbsshqee.js | 2 - .../fabro-spa/assets/assets/chunk-xcxrzfy1.js | 2 - .../fabro-spa/assets/assets/chunk-xr25x186.js | 2 - .../fabro-spa/assets/assets/chunk-xsg5yrft.js | 2 - .../fabro-spa/assets/assets/chunk-xsjq18b3.js | 2 - .../fabro-spa/assets/assets/chunk-xvjs9feb.js | 2 - .../fabro-spa/assets/assets/chunk-yc4p6sd7.js | 2 - .../fabro-spa/assets/assets/chunk-yj17k4xm.js | 2 - .../fabro-spa/assets/assets/chunk-ym5wbpcw.js | 2 - .../fabro-spa/assets/assets/chunk-yme4dsd5.js | 2 - .../fabro-spa/assets/assets/chunk-ysp46zk7.js | 2 - .../fabro-spa/assets/assets/chunk-yxgc10tc.js | 2 - .../fabro-spa/assets/assets/chunk-yy4ek5m1.js | 2 - .../fabro-spa/assets/assets/chunk-z512569f.js | 2 - .../fabro-spa/assets/assets/chunk-z7w8z7he.js | 2 - .../fabro-spa/assets/assets/chunk-z9bs2a1h.js | 2 - .../fabro-spa/assets/assets/chunk-z9yhre5d.js | 2 - .../fabro-spa/assets/assets/chunk-zb6gezq1.js | 2 - .../fabro-spa/assets/assets/chunk-zccvn657.js | 2 - .../fabro-spa/assets/assets/chunk-zcmpnmq4.js | 2 - .../fabro-spa/assets/assets/chunk-zhw3gev4.js | 2 - .../fabro-spa/assets/assets/chunk-zjwtp3n6.js | 2 - .../fabro-spa/assets/assets/chunk-zv090btz.js | 2 - .../fabro-spa/assets/assets/entry-arnv5m0e.js | 1972 ----------------- .../fabro-spa/assets/assets/entry-czj6w8kp.js | 1970 ++++++++++++++++ lib/crates/fabro-spa/assets/index.html | 2 +- lib/crates/fabro-test/src/lib.rs | 78 +- lib/crates/fabro-util/src/home.rs | 32 +- railway.toml | 2 +- 390 files changed, 7088 insertions(+), 2876 deletions(-) create mode 100644 apps/fabro-web/app/install-api.test.ts create mode 100644 apps/fabro-web/app/install-api.ts create mode 100644 apps/fabro-web/app/install-app.tsx create mode 100644 apps/fabro-web/app/install-router.tsx create mode 100644 apps/fabro-web/app/mode.test.ts create mode 100644 apps/fabro-web/app/mode.ts delete mode 100644 docker/settings.toml create mode 100644 lib/crates/fabro-install/Cargo.toml create mode 100644 lib/crates/fabro-install/src/lib.rs create mode 100644 lib/crates/fabro-server/src/install.rs create mode 100644 lib/crates/fabro-server/tests/it/api/install.rs delete mode 100644 lib/crates/fabro-spa/assets/assets/entry-arnv5m0e.js create mode 100644 lib/crates/fabro-spa/assets/assets/entry-czj6w8kp.js diff --git a/Cargo.lock b/Cargo.lock index a1804ed1b..744d31492 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1758,6 +1758,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "fabro-install" +version = "0.208.0-nightly.0" +dependencies = [ + "anyhow", + "base64", + "fabro-config", + "fabro-types", + "fabro-vault", + "ring", + "tempfile", + "toml 0.8.23", +] + [[package]] name = "fabro-interview" version = "0.208.0-nightly.0" @@ -1932,6 +1946,7 @@ dependencies = [ "fabro-graphviz", "fabro-hooks", "fabro-http", + "fabro-install", "fabro-interview", "fabro-llm", "fabro-model", diff --git a/Dockerfile b/Dockerfile index 6ad1d4a01..29ff48dbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,16 +24,14 @@ RUN apk add --no-cache \ tini \ && addgroup -S -g 1000 fabro \ && adduser -S -u 1000 -G fabro -h /var/fabro -s /sbin/nologin fabro \ - && install -d -o fabro -g fabro -m 0755 /var/fabro /storage \ - && install -d -m 0755 /etc/fabro + && install -d -o fabro -g fabro -m 0755 /var/fabro /storage COPY --chmod=0755 docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro -COPY docker/settings.toml /etc/fabro/settings.toml COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/fabro-entrypoint -ENV FABRO_HOME=/var/fabro \ - FABRO_CONFIG=/etc/fabro/settings.toml +ENV FABRO_HOME=/storage/.home \ + FABRO_STORAGE_DIR=/storage VOLUME ["/storage"] EXPOSE 32276 diff --git a/apps/fabro-web/app/entry.tsx b/apps/fabro-web/app/entry.tsx index 542dec68a..d043cfc03 100644 --- a/apps/fabro-web/app/entry.tsx +++ b/apps/fabro-web/app/entry.tsx @@ -1,9 +1,19 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router"; +import { installRoutes } from "./install-router"; +import { resolveFabroMode } from "./mode"; import { routes } from "./router"; -const router = createBrowserRouter(routes); +declare global { + interface Window { + __FABRO_MODE__?: string; + } +} + +const router = createBrowserRouter( + resolveFabroMode(window.__FABRO_MODE__) === "install" ? installRoutes : routes, +); const rootElement = document.getElementById("root"); if (!rootElement) { diff --git a/apps/fabro-web/app/install-api.test.ts b/apps/fabro-web/app/install-api.test.ts new file mode 100644 index 000000000..8ead4355a --- /dev/null +++ b/apps/fabro-web/app/install-api.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { buildGithubOwnerValue, readInstallError } from "./install-api"; + +describe("readInstallError", () => { + test("prefers the structured install error payload", async () => { + const response = new Response(JSON.stringify({ error: "invalid token" }), { + status: 422, + headers: { "Content-Type": "application/json" }, + }); + + await expect( + readInstallError(response, "install request failed"), + ).resolves.toBe("invalid token"); + }); + + test("falls back to the provided message when the body is not structured JSON", async () => { + const response = new Response("boom", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }); + + await expect( + readInstallError(response, "install request failed"), + ).resolves.toBe("install request failed (500)"); + }); +}); + +describe("buildGithubOwnerValue", () => { + test("uses personal for personal app installs", () => { + expect(buildGithubOwnerValue("personal", "")).toBe("personal"); + }); + + test("formats organization owners with the expected prefix", () => { + expect(buildGithubOwnerValue("org", " acme ")).toBe("org:acme"); + }); +}); diff --git a/apps/fabro-web/app/install-api.ts b/apps/fabro-web/app/install-api.ts new file mode 100644 index 000000000..7ee45540e --- /dev/null +++ b/apps/fabro-web/app/install-api.ts @@ -0,0 +1,208 @@ +export interface InstallSessionResponse { + completed_steps: string[]; + llm: + | { + providers: Array<{ + provider: string; + configured: boolean; + openai_base_url?: string | null; + }>; + } + | null; + server: { canonical_url: string } | null; + github: + | { + strategy: string; + username?: string; + owner?: string; + app_name?: string; + slug?: string; + allowed_username?: string; + } + | null; + prefill: { canonical_url: string }; +} + +export interface InstallFinishResponse { + status: "completing"; + restart_url: string; + dev_token: string; +} + +export interface InstallLlmProviderInput { + provider: string; + api_key: string; + openai_base_url?: string | null; +} + +export interface InstallGithubAppManifestInput { + owner: string; + app_name: string; + allowed_username: string; +} + +export interface InstallGithubAppManifestResponse { + manifest: Record; + github_form_action: string; +} + +const INSTALL_TOKEN_KEY = "fabro-install-token"; + +export function readStoredInstallToken(): string | null { + try { + return window.sessionStorage.getItem(INSTALL_TOKEN_KEY); + } catch { + return null; + } +} + +export function persistInstallToken(token: string | null): void { + try { + if (token) { + window.sessionStorage.setItem(INSTALL_TOKEN_KEY, token); + } else { + window.sessionStorage.removeItem(INSTALL_TOKEN_KEY); + } + } catch { + // best-effort only + } +} + +async function installFetch(path: string, token: string, init?: RequestInit): Promise { + return fetch(path, { + ...init, + headers: { + ...(init?.headers ?? {}), + Authorization: `Bearer ${token}`, + }, + }); +} + +export async function readInstallError( + response: Response, + fallback: string, +): Promise { + try { + const body = (await response.clone().json()) as { error?: string }; + if (body.error) return body.error; + } catch { + // fall through to the default message + } + return `${fallback} (${response.status})`; +} + +export function buildGithubOwnerValue( + ownerKind: "personal" | "org", + organizationSlug: string, +): string { + return ownerKind === "org" + ? `org:${organizationSlug.trim()}` + : "personal"; +} + +export async function getInstallSession(token: string): Promise { + const response = await installFetch("/install/session", token); + if (!response.ok) { + throw new Error(await readInstallError(response, "install session request failed")); + } + return response.json() as Promise; +} + +export async function testInstallLlm( + token: string, + provider: InstallLlmProviderInput, +): Promise { + const response = await installFetch("/install/llm/test", token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(provider), + }); + if (!response.ok) { + throw new Error(await readInstallError(response, "install llm validation failed")); + } +} + +export async function putInstallLlm( + token: string, + providers: InstallLlmProviderInput[], +): Promise { + const response = await installFetch("/install/llm", token, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providers }), + }); + if (!response.ok) { + throw new Error(await readInstallError(response, "install llm request failed")); + } +} + +export async function putInstallServer(token: string, canonicalUrl: string): Promise { + const response = await installFetch("/install/server", token, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ canonical_url: canonicalUrl }), + }); + if (!response.ok) { + throw new Error(await readInstallError(response, "install server request failed")); + } +} + +export async function testInstallGithubToken( + token: string, + githubToken: string, +): Promise { + const response = await installFetch("/install/github/token/test", token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: githubToken }), + }); + if (!response.ok) { + throw new Error( + await readInstallError(response, "install github token validation failed"), + ); + } + const body = (await response.json()) as { username: string }; + return body.username; +} + +export async function putInstallGithubToken( + token: string, + githubToken: string, + username: string, +): Promise { + const response = await installFetch("/install/github/token", token, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: githubToken, username }), + }); + if (!response.ok) { + throw new Error(await readInstallError(response, "install github token request failed")); + } +} + +export async function createInstallGithubAppManifest( + token: string, + input: InstallGithubAppManifestInput, +): Promise { + const response = await installFetch("/install/github/app/manifest", token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!response.ok) { + throw new Error( + await readInstallError(response, "install github app manifest request failed"), + ); + } + return response.json() as Promise; +} + +export async function finishInstall(token: string): Promise { + const response = await installFetch("/install/finish", token, { + method: "POST", + }); + if (!response.ok) { + throw new Error(await readInstallError(response, "install finish request failed")); + } + return response.json() as Promise; +} diff --git a/apps/fabro-web/app/install-app.tsx b/apps/fabro-web/app/install-app.tsx new file mode 100644 index 000000000..6e49949e5 --- /dev/null +++ b/apps/fabro-web/app/install-app.tsx @@ -0,0 +1,1121 @@ +import { startTransition, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { Link, Navigate, useLocation, useNavigate } from "react-router"; + +import { + type InstallFinishResponse, + type InstallLlmProviderInput, + type InstallSessionResponse, + buildGithubOwnerValue, + createInstallGithubAppManifest, + finishInstall, + getInstallSession, + persistInstallToken, + putInstallGithubToken, + putInstallLlm, + putInstallServer, + readStoredInstallToken, + testInstallGithubToken, + testInstallLlm, +} from "./install-api"; +import { AuthLayout } from "./components/auth-layout"; +import { consumeInstallTokenFromUrl } from "./mode"; + +const INSTALL_STEPS = [ + { id: "welcome", label: "Welcome", href: "/install/welcome" }, + { id: "llm", label: "LLM", href: "/install/llm" }, + { id: "server", label: "Server", href: "/install/server" }, + { id: "github", label: "GitHub", href: "/install/github" }, + { id: "review", label: "Review", href: "/install/review" }, +] as const; + +const PROVIDERS = [ + { + id: "anthropic", + label: "Anthropic", + hint: "Claude API key.", + }, + { + id: "openai", + label: "OpenAI", + hint: "Responses API key.", + }, + { + id: "gemini", + label: "Gemini", + hint: "Google AI Studio API key.", + }, + { + id: "openai_compatible", + label: "OpenAI Compatible", + hint: "API key plus a custom base URL.", + }, +] as const; + +type StepId = (typeof INSTALL_STEPS)[number]["id"]; +type FinishState = InstallFinishResponse | null; +type GithubStrategy = "token" | "app"; +type GithubOwnerKind = "personal" | "org"; + +type ProviderSelection = Record< + string, + { + apiKey: string; + openaiBaseUrl: string; + } +>; + +export default function InstallApp() { + const navigate = useNavigate(); + const location = useLocation(); + const [installToken, setInstallToken] = useState(() => + readStoredInstallToken(), + ); + const [session, setSession] = useState(null); + const [loadingSession, setLoadingSession] = useState(false); + const [sessionError, setSessionError] = useState(null); + const [manualToken, setManualToken] = useState(""); + const [llmSelection, setLlmSelection] = useState(() => + defaultProviderSelection(), + ); + const [canonicalUrl, setCanonicalUrl] = useState(""); + const [githubStrategy, setGithubStrategy] = useState("token"); + const [githubToken, setGithubToken] = useState(""); + const [githubUsername, setGithubUsername] = useState(""); + const [githubOwnerKind, setGithubOwnerKind] = + useState("personal"); + const [githubOrganization, setGithubOrganization] = useState(""); + const [githubAppName, setGithubAppName] = useState("Fabro"); + const [githubAllowedUsername, setGithubAllowedUsername] = useState(""); + const [saveError, setSaveError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [finishState, setFinishState] = useState(null); + const [timedOut, setTimedOut] = useState(false); + + useEffect(() => { + const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href); + if (!token) return; + + persistInstallToken(token); + setInstallToken(token); + window.history.replaceState(window.history.state, "", sanitizedUrl); + }, []); + + useEffect(() => { + setSaveError(null); + }, [location.pathname]); + + useEffect(() => { + if (!installToken) { + setSession(null); + return; + } + + let cancelled = false; + setLoadingSession(true); + setSessionError(null); + getInstallSession(installToken) + .then((nextSession) => { + if (cancelled) return; + setSession(nextSession); + setCanonicalUrl((current) => + current || nextSession.server?.canonical_url || nextSession.prefill.canonical_url, + ); + setLlmSelection((current) => + hydrateProviderSelection(current, nextSession), + ); + if (nextSession.github?.strategy === "app") { + setGithubStrategy("app"); + const owner = nextSession.github.owner ?? "personal"; + if (owner.startsWith("org:")) { + setGithubOwnerKind("org"); + setGithubOrganization(owner.slice(4)); + } else { + setGithubOwnerKind("personal"); + setGithubOrganization(""); + } + setGithubAppName(nextSession.github.app_name || "Fabro"); + setGithubAllowedUsername(nextSession.github.allowed_username || ""); + } else if (nextSession.github?.strategy === "token") { + setGithubStrategy("token"); + setGithubUsername(nextSession.github.username || ""); + } + }) + .catch((error) => { + if (cancelled) return; + setSession(null); + setSessionError(error instanceof Error ? error.message : "Install session failed"); + }) + .finally(() => { + if (!cancelled) setLoadingSession(false); + }); + + return () => { + cancelled = true; + }; + }, [installToken]); + + useEffect(() => { + if (!installToken || !session) return; + if ((location.pathname === "/" || location.pathname === "/install") && !finishState) { + startTransition(() => { + navigate("/install/welcome", { replace: true }); + }); + } + }, [finishState, installToken, location.pathname, navigate, session]); + + useEffect(() => { + if (!finishState) return; + + setTimedOut(false); + const deadline = window.setTimeout(() => { + setTimedOut(true); + }, 30_000); + + const interval = window.setInterval(async () => { + try { + const response = await fetch("/health"); + if (!response.ok) { + window.location.href = finishState.restart_url; + return; + } + const body = (await response.json()) as { mode?: string }; + if (body.mode !== "install") { + window.location.href = finishState.restart_url; + } + } catch { + window.location.href = finishState.restart_url; + } + }, 1_000); + + return () => { + window.clearTimeout(deadline); + window.clearInterval(interval); + }; + }, [finishState]); + + const currentStep = useMemo(() => { + if (location.pathname.startsWith("/install/llm")) return "llm"; + if (location.pathname.startsWith("/install/server")) return "server"; + if (location.pathname.startsWith("/install/github")) return "github"; + if (location.pathname.startsWith("/install/review")) return "review"; + return "welcome"; + }, [location.pathname]); + + const completedSteps = new Set(session?.completed_steps ?? []); + + if (!installToken) { + return ( + { + const nextToken = manualToken.trim(); + if (!nextToken) { + setSessionError("Paste the install token from the server logs."); + return; + } + persistInstallToken(nextToken); + setInstallToken(nextToken); + setSessionError(null); + }} + /> + ); + } + + if (loadingSession && !session) { + return ( + + + Reading the current install state from the server. + + + ); + } + + if (sessionError && !session) { + return ( + { + const nextToken = manualToken.trim(); + persistInstallToken(nextToken); + setInstallToken(nextToken || null); + }} + /> + ); + } + + if (finishState && location.pathname !== "/install/finishing") { + return ; + } + + return ( + + {location.pathname === "/install/finishing" ? ( + + ) : location.pathname === "/install/llm" ? ( + { + const providers = PROVIDERS.map(({ id }) => { + const current = llmSelection[id] ?? { apiKey: "", openaiBaseUrl: "" }; + return { + provider: id, + api_key: current.apiKey.trim(), + openai_base_url: current.openaiBaseUrl.trim() || null, + }; + }).filter((provider) => provider.api_key.length > 0); + + if (providers.length === 0) { + setSaveError("Add at least one provider API key before continuing."); + return; + } + + setSubmitting(true); + setSaveError(null); + try { + for (const provider of providers) { + await testInstallLlm(installToken, provider); + } + await putInstallLlm(installToken, providers); + const nextSession = await getInstallSession(installToken); + setSession(nextSession); + navigate("/install/server"); + } catch (error) { + setSaveError( + error instanceof Error ? error.message : "Failed to save LLM settings.", + ); + } finally { + setSubmitting(false); + } + }} + > + + + ) : location.pathname === "/install/server" ? ( + { + if (!canonicalUrl.trim()) { + setSaveError("Enter the canonical server URL before continuing."); + return; + } + setSubmitting(true); + setSaveError(null); + try { + await putInstallServer(installToken, canonicalUrl.trim()); + const nextSession = await getInstallSession(installToken); + setSession(nextSession); + navigate("/install/github"); + } catch (error) { + setSaveError( + error instanceof Error ? error.message : "Failed to save server settings.", + ); + } finally { + setSubmitting(false); + } + }} + > + + setCanonicalUrl(event.target.value)} + className={INPUT_CLASS} + placeholder="https://fabro.example.com" + /> + + + ) : location.pathname === "/install/github/done" ? ( + + ) : location.pathname === "/install/github" ? ( + { + setSubmitting(true); + setSaveError(null); + try { + if (githubStrategy === "token") { + if (!githubToken.trim()) { + setSaveError("Provide the GitHub token before continuing."); + return; + } + const username = await testInstallGithubToken( + installToken, + githubToken.trim(), + ); + setGithubUsername(username); + await putInstallGithubToken(installToken, githubToken.trim(), username); + const nextSession = await getInstallSession(installToken); + setSession(nextSession); + navigate("/install/review"); + return; + } + + if (githubOwnerKind === "org" && !githubOrganization.trim()) { + setSaveError("Enter the organization slug for the GitHub App."); + return; + } + if (!githubAppName.trim()) { + setSaveError("Enter the GitHub App name before continuing."); + return; + } + if (!githubAllowedUsername.trim()) { + setSaveError("Enter the GitHub username that should be allowed to log in."); + return; + } + + const manifest = await createInstallGithubAppManifest(installToken, { + owner: buildGithubOwnerValue(githubOwnerKind, githubOrganization), + app_name: githubAppName.trim(), + allowed_username: githubAllowedUsername.trim(), + }); + submitGithubManifest(manifest.github_form_action, manifest.manifest); + } catch (error) { + setSaveError( + error instanceof Error ? error.message : "Failed to start GitHub setup.", + ); + } finally { + setSubmitting(false); + } + }} + > + + {githubStrategy === "token" ? ( + <> + +