mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
feat(install): make LLM setup optional in web installer and CLI (#265)
Some checks failed
Rust / Clippy (push) Waiting to run
Rust / Format (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
Some checks failed
Rust / Clippy (push) Waiting to run
Rust / Format (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
## Summary
Makes LLM setup explicitly skippable in both the web installer and
`fabro install`, without making omission accidental. A skipped LLM step
lets install complete with zero LLM credentials; later LLM-dependent
workflows keep using the existing provider-not-configured behavior.
`fabro doctor` is intentionally unchanged.
Plan: `docs/superpowers/plans/2026-05-14-optional-llm-install.md`
## Key changes
**Server + API**
- `PUT /install/llm` now accepts `{"providers":[]}` as "LLM step
completed, skipped" — the empty-list rejection is removed; per-provider
validation for non-empty lists is retained.
- OpenAPI: dropped `minItems: 1` from
`InstallLlmProvidersInput.providers`, updated schema descriptions so
empty = skipped and `llm: null` = incomplete. TypeScript client
regenerated.
- `/install/finish` still requires the LLM step to be present, but
tolerates zero credentials — it writes settings, runtime auth secrets,
and GitHub secrets normally and writes no LLM vault entries.
**Web installer**
- New "Skip LLM setup" secondary action on the LLM step (via a
`secondaryAction` prop on `StepPanel`) that records an empty provider
list and advances to GitHub.
- Review screen shows `LLM providers: Skipped` (step completed, empty)
vs `Not configured` (step never completed), via a new
`describeLlmSummary` helper.
- Continue with no API keys still shows the existing validation error —
skipping is only reachable through the explicit skip action.
**CLI**
- Interactive `fabro install` asks "Configure LLM providers now?"
(default yes) before provider selection; declining returns an empty
selection and continues to GitHub.
- Hidden non-interactive `--skip-llm` flag, mutually exclusive with
`--llm-provider` / `--llm-api-key-stdin` / `--llm-api-key-env` via clap
`conflicts_with_all`. Missing LLM flags are still validation errors
unless `--skip-llm` is present. Non-interactive usage text updated with
a skip example.
## Code review
Ran a 12-reviewer `ce:review` pass (correctness, testing,
maintainability, project-standards, agent-native, learnings, security,
api-contract, reliability, adversarial, cli-readiness,
kieran-typescript). No P0/P1 findings; agent-native parity PASS. Applied
fixes in `40a29c591`:
- Re-entrancy guard on `runStepSubmit` so a fast double-click on "Skip
LLM setup" can't fire two requests.
- `validate()` only suggests `--skip-llm` in the missing-provider error
when no credential flag is set (it conflicts with those flags).
- Added tests: all three `--skip-llm` conflict arms, the review screen's
"Not configured" branch, and the skip-button failure path.
One advisory finding left as report-only: an empty `PUT /install/llm`
overwrites previously-saved credentials if a user navigates Back and
clicks Skip — judged acceptable since the button is explicitly labeled
and clicking it is deliberate.
## Testing
- `cargo nextest run -p fabro-server -p fabro-cli -p fabro-install` —
1521 passed
- `cargo build -p fabro-api`, `cargo fmt --check`, `cargo clippy`
(changed crates) — clean
- `bun test` (install-app) — 14 passed; `bun run typecheck` — clean
- New coverage: server accepts empty providers + session shows `llm`
complete with `providers:[]`; finish with skipped LLM persists no LLM
vault credentials but keeps GitHub secrets; web skip button PUTs
`providers:[]` and navigates to GitHub; review renders Skipped / Not
configured; CLI `--skip-llm` requires `--non-interactive`, conflicts
with all credential flags, `validate()` succeeds with `--skip-llm`,
usage text documents `--skip-llm`.
Not added (out of plan scope): an automated test for the interactive
`InstallInputSource` skip branch — `InteractiveInstallInputSource` is
TTY-coupled and has no existing tests; the non-interactive `--skip-llm`
path is fully covered.
## Post-Deploy Monitoring & Validation
This change is install-time only; there is no continuous runtime impact.
Validate during the next install/release smoke:
- **Web installer:** run a fresh browser install, click "Skip LLM setup"
on the LLM step, confirm it advances to GitHub and the review screen
reads `LLM providers: Skipped`. Finish the install and confirm the
server restarts into normal mode with no LLM credentials in the vault
(`secrets.json` has no credential entries) and
GitHub/server/object-store/sandbox settings written normally.
- **CLI:** run `fabro install --non-interactive --skip-llm
--github-strategy token --github-username <user>` and confirm it
completes; run interactive `fabro install` and confirm declining
"Configure LLM providers now?" continues to GitHub.
- **Healthy signals:** install completes (web `/install/finish` → 202;
CLI exits 0), server boots in normal mode, `fabro doctor` runs and
reports no LLM providers configured (expected, unchanged behavior).
- **Failure signals / rollback trigger:** install fails to finish,
server fails to boot after a skipped install, or `/install/finish`
rejects a completed-but-empty LLM step. Rollback = revert this PR;
install behavior returns to requiring at least one LLM provider.
- **Validation window/owner:** next install smoke / release
verification, owned by whoever runs the release.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
869b94c0c4
commit
32f100cbe7
12 changed files with 821 additions and 44 deletions
|
|
@ -904,4 +904,382 @@ describe("InstallApp", () => {
|
|||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("skips LLM setup with an empty providers list and advances to GitHub", async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalConsoleError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("react-test-renderer is deprecated")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
const fetchMock = mock((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
if (String(input) === "/install/session" && fetchCalls.length === 1) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
completed_steps: ["server", "object_store", "sandbox"],
|
||||
llm: null,
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: { provider: "local" },
|
||||
sandbox: { provider: "docker" },
|
||||
github: null,
|
||||
prefill: INSTALL_PREFILL,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (String(input) === "/install/llm") {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
if (String(input) === "/install/session") {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
completed_steps: ["server", "object_store", "sandbox", "llm"],
|
||||
llm: { providers: [] },
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: { provider: "local" },
|
||||
sandbox: { provider: "docker" },
|
||||
github: null,
|
||||
prefill: INSTALL_PREFILL,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
});
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/llm");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
(globalThis as { window?: unknown }).window = testWindow;
|
||||
|
||||
let renderer: TestRenderer.ReactTestRenderer | null = null;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<MemoryRouter initialEntries={["/install/llm"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
|
||||
});
|
||||
|
||||
const skipButton = renderer!.root.findAll(
|
||||
(node) => node.type === "button" && node.children.includes("Skip LLM setup"),
|
||||
)[0];
|
||||
expect(skipButton).toBeDefined();
|
||||
await act(async () => {
|
||||
skipButton!.props.onClick();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderTreeText(renderer!.toJSON())).toContain("Connect GitHub");
|
||||
});
|
||||
|
||||
const calls = fetchCalls.map((call) => String(call.input));
|
||||
const putIdx = calls.indexOf("/install/llm");
|
||||
expect(putIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(fetchCalls[putIdx]?.init?.body).toBe(JSON.stringify({ providers: [] }));
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks Continue on the LLM step when no API keys are entered", async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalConsoleError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("react-test-renderer is deprecated")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
const fetchMock = mock((input: RequestInfo | URL) => {
|
||||
if (String(input) === "/install/session") {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
completed_steps: ["server", "object_store", "sandbox"],
|
||||
llm: null,
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: { provider: "local" },
|
||||
sandbox: { provider: "docker" },
|
||||
github: null,
|
||||
prefill: INSTALL_PREFILL,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
});
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/llm");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
(globalThis as { window?: unknown }).window = testWindow;
|
||||
|
||||
let renderer: TestRenderer.ReactTestRenderer | null = null;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<MemoryRouter initialEntries={["/install/llm"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
|
||||
});
|
||||
|
||||
const form = renderer!.root.findByType("form");
|
||||
await act(async () => {
|
||||
form.props.onSubmit({ preventDefault() {} });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderTreeText(renderer!.toJSON())).toContain(
|
||||
"Add at least one provider API key before continuing.",
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("shows a skipped LLM step as Skipped on the review step", async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalConsoleError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("react-test-renderer is deprecated")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
const fetchMock = mock((input: RequestInfo | URL) => {
|
||||
expect(String(input)).toBe("/install/session");
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
completed_steps: ["server", "object_store", "sandbox", "llm", "github"],
|
||||
llm: { providers: [] },
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: { provider: "local" },
|
||||
sandbox: { provider: "docker" },
|
||||
github: { strategy: "token", username: "octocat" },
|
||||
prefill: INSTALL_PREFILL,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
});
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/review");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
(globalThis as { window?: unknown }).window = testWindow;
|
||||
|
||||
let renderer: TestRenderer.ReactTestRenderer | null = null;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<MemoryRouter initialEntries={["/install/review"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const text = renderTreeText(renderer!.toJSON());
|
||||
expect(text).toContain("LLM providers");
|
||||
expect(text).toContain("Skipped");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an incomplete LLM step as Not configured on the review step", async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalConsoleError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("react-test-renderer is deprecated")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
const fetchMock = mock((input: RequestInfo | URL) => {
|
||||
expect(String(input)).toBe("/install/session");
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
completed_steps: ["server", "object_store", "sandbox", "github"],
|
||||
llm: null,
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: { provider: "local" },
|
||||
sandbox: { provider: "docker" },
|
||||
github: { strategy: "token", username: "octocat" },
|
||||
prefill: INSTALL_PREFILL,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
});
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/review");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
(globalThis as { window?: unknown }).window = testWindow;
|
||||
|
||||
let renderer: TestRenderer.ReactTestRenderer | null = null;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<MemoryRouter initialEntries={["/install/review"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const text = renderTreeText(renderer!.toJSON());
|
||||
expect(text).toContain("LLM providers");
|
||||
expect(text).toContain("Not configured");
|
||||
});
|
||||
expect(renderTreeText(renderer!.toJSON())).not.toContain("Skipped");
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the user on the LLM step when skipping fails", async () => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalConsoleError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("react-test-renderer is deprecated")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
const fetchMock = mock((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
if (String(input) === "/install/session") {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
completed_steps: ["server", "object_store", "sandbox"],
|
||||
llm: null,
|
||||
server: { canonical_url: "https://fabro.example.com" },
|
||||
object_store: { provider: "local" },
|
||||
sandbox: { provider: "docker" },
|
||||
github: null,
|
||||
prefill: INSTALL_PREFILL,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (String(input) === "/install/llm") {
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
});
|
||||
useInstallFetchMock(fetchMock as typeof fetch);
|
||||
|
||||
const testWindow = createTestWindow("https://fabro.example.com/install/llm");
|
||||
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
|
||||
(globalThis as { window?: unknown }).window = testWindow;
|
||||
|
||||
let renderer: TestRenderer.ReactTestRenderer | null = null;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<MemoryRouter initialEntries={["/install/llm"]}>
|
||||
<Routes>
|
||||
<Route path="/install/*" element={<InstallApp />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
|
||||
});
|
||||
|
||||
const skipButton = renderer!.root.findAll(
|
||||
(node) => node.type === "button" && node.children.includes("Skip LLM setup"),
|
||||
)[0];
|
||||
await act(async () => {
|
||||
skipButton!.props.onClick();
|
||||
});
|
||||
|
||||
// The failed PUT must not advance to GitHub or refresh the session.
|
||||
await waitFor(() => {
|
||||
expect(fetchCalls.map((call) => String(call.input))).toContain("/install/llm");
|
||||
});
|
||||
const text = renderTreeText(renderer!.toJSON());
|
||||
expect(text).toContain("Add your LLM credentials");
|
||||
expect(text).not.toContain("Connect GitHub");
|
||||
expect(
|
||||
fetchCalls.filter((call) => String(call.input) === "/install/session"),
|
||||
).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -304,6 +304,11 @@ export default function InstallApp() {
|
|||
fallback: string;
|
||||
next?: string;
|
||||
}) => {
|
||||
// Re-entrancy guard: the StepPanel form guards its own onSubmit, but the
|
||||
// LLM step's "Skip LLM setup" button calls this directly, so a fast
|
||||
// double-click could otherwise fire two requests before `submitting`
|
||||
// re-renders the disabled state.
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
|
|
@ -359,10 +364,26 @@ export default function InstallApp() {
|
|||
) : location.pathname === "/install/llm" ? (
|
||||
<StepPanel
|
||||
title="Add your LLM credentials"
|
||||
description="Each key you enter is validated before it's saved. Skip a provider by leaving it blank."
|
||||
description="Each key you enter is validated before it's saved. Skip a provider by leaving it blank, or skip LLM setup entirely and configure providers later."
|
||||
error={saveError}
|
||||
submitting={submitting}
|
||||
backHref="/install/sandbox"
|
||||
secondaryAction={
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
void runStepSubmit({
|
||||
action: () => putInstallLlm(installToken, []),
|
||||
fallback: "Failed to skip LLM setup.",
|
||||
next: "/install/github",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Skip LLM setup
|
||||
</button>
|
||||
}
|
||||
onSubmit={async () => {
|
||||
const providers = INSTALL_PROVIDERS.map(({ id }) => {
|
||||
const current = llmSelection[id] ?? { apiKey: "" };
|
||||
|
|
@ -1164,6 +1185,7 @@ function StepPanel({
|
|||
submitLabel = "Continue",
|
||||
submittingLabel = "Saving...",
|
||||
backHref,
|
||||
secondaryAction,
|
||||
onSubmit,
|
||||
}: {
|
||||
title: string;
|
||||
|
|
@ -1174,6 +1196,7 @@ function StepPanel({
|
|||
submitLabel?: string;
|
||||
submittingLabel?: string;
|
||||
backHref?: string;
|
||||
secondaryAction?: ReactNode;
|
||||
onSubmit: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -1204,19 +1227,22 @@ function StepPanel({
|
|||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button type="submit" disabled={submitting} className={PRIMARY_BUTTON_CLASS}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Spinner />
|
||||
{submittingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{submitLabel}
|
||||
<ArrowRightIcon className="size-4 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{secondaryAction}
|
||||
<button type="submit" disabled={submitting} className={PRIMARY_BUTTON_CLASS}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Spinner />
|
||||
{submittingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{submitLabel}
|
||||
<ArrowRightIcon className="size-4 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
|
@ -1233,9 +1259,7 @@ function ReviewScreen({
|
|||
submitting: boolean;
|
||||
onInstall: () => Promise<void>;
|
||||
}) {
|
||||
const providers = (session?.llm?.providers ?? [])
|
||||
.map((provider) => describeProvider(provider.provider))
|
||||
.join(", ");
|
||||
const llmSummary = describeLlmSummary(session?.llm);
|
||||
const serverUrl =
|
||||
session?.server?.canonical_url || session?.prefill.canonical_url || "Unknown";
|
||||
return (
|
||||
|
|
@ -1265,7 +1289,7 @@ function ReviewScreen({
|
|||
/>
|
||||
{renderObjectStoreSummaryRows(session?.object_store)}
|
||||
{renderSandboxSummaryRows(session?.sandbox)}
|
||||
<SummaryRow label="LLM providers" value={providers || "Not configured"} />
|
||||
<SummaryRow label="LLM providers" value={llmSummary} />
|
||||
{renderGithubSummaryRows(session?.github, serverUrl)}
|
||||
</dl>
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
|
|
@ -1872,6 +1896,18 @@ function describeProvider(id: string): string {
|
|||
return match?.label ?? id;
|
||||
}
|
||||
|
||||
function describeLlmSummary(llm: InstallSessionResponse["llm"]): string {
|
||||
// `null` means the LLM step has not been completed. A present summary with
|
||||
// an empty providers list is an explicit skip.
|
||||
if (!llm) {
|
||||
return "Not configured";
|
||||
}
|
||||
const providers = (llm.providers ?? []).map((provider) =>
|
||||
describeProvider(provider.provider),
|
||||
);
|
||||
return providers.length > 0 ? providers.join(", ") : "Skipped";
|
||||
}
|
||||
|
||||
function renderGithubSummaryRows(
|
||||
github: InstallSessionResponse["github"],
|
||||
serverUrl: string,
|
||||
|
|
|
|||
|
|
@ -143,7 +143,10 @@ paths:
|
|||
operationId: putInstallLlm
|
||||
tags: [Install]
|
||||
summary: Save install LLM settings
|
||||
description: Records the LLM providers and API keys chosen during the browser install. Requires the one-time install token.
|
||||
description: >-
|
||||
Records the LLM providers and API keys chosen during the browser
|
||||
install. An empty `providers` list marks the LLM step as completed
|
||||
and explicitly skipped. Requires the one-time install token.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
|
|
@ -3978,14 +3981,18 @@ components:
|
|||
type: string
|
||||
|
||||
InstallLlmProvidersInput:
|
||||
description: LLM providers selected during browser install.
|
||||
description: >-
|
||||
LLM providers selected during browser install. An empty `providers`
|
||||
list explicitly marks the LLM step as completed and skipped.
|
||||
type: object
|
||||
required:
|
||||
- providers
|
||||
properties:
|
||||
providers:
|
||||
type: array
|
||||
minItems: 1
|
||||
description: >-
|
||||
LLM providers to persist. An empty list records an explicit skip:
|
||||
the LLM step is marked complete with zero credentials.
|
||||
items:
|
||||
$ref: "#/components/schemas/InstallLlmProviderInput"
|
||||
|
||||
|
|
@ -4003,7 +4010,10 @@ components:
|
|||
type: string
|
||||
|
||||
InstallLlmSummary:
|
||||
description: Redacted summary of persisted LLM install choices.
|
||||
description: >-
|
||||
Redacted summary of persisted LLM install choices. Present with an
|
||||
empty `providers` list when the LLM step was explicitly skipped;
|
||||
`null` on the install session means the step is still incomplete.
|
||||
type: object
|
||||
properties:
|
||||
providers:
|
||||
|
|
|
|||
65
docs/superpowers/plans/2026-05-14-optional-llm-install.md
Normal file
65
docs/superpowers/plans/2026-05-14-optional-llm-install.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Optional LLM Setup in Installers Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make LLM setup explicitly skippable in both the web installer and `fabro install`, while preserving current behavior for users who do configure LLMs.
|
||||
|
||||
**Architecture:** Represent an explicit web-installer skip as a completed LLM step with an empty provider list. Represent an explicit CLI skip with an interactive confirmation path and a hidden non-interactive `--skip-llm` flag. Runtime model resolution and doctor behavior remain unchanged.
|
||||
|
||||
**Tech Stack:** Rust CLI/server (`fabro-cli`, `fabro-server`, `fabro-api`), OpenAPI-generated clients, React/TypeScript web installer, Bun tests, cargo-nextest.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Make LLM setup optional without making omission accidental. Users can explicitly skip LLM setup and still complete installation. A skipped LLM step means install can complete with zero LLM credentials; later LLM-dependent workflows keep using existing provider-not-configured behavior. Per product decision, `fabro doctor` remains unchanged and may report no LLM providers configured.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- **Web install API:** Treat `PUT /install/llm` with `{"providers":[]}` as "LLM step completed, skipped."
|
||||
- Remove `minItems: 1` from `InstallLlmProvidersInput.providers` in `docs/public/api-reference/fabro-api.yaml`.
|
||||
- Update schema descriptions so empty `providers` explicitly means skipped.
|
||||
- Keep `/install/finish` requiring the LLM step to be completed, but allow the completed step to contain zero providers.
|
||||
- **Web UI:** Add an explicit "Skip LLM setup" secondary action on the LLM step.
|
||||
- It sends `putInstallLlm(token, [])`, refreshes the install session, and advances to GitHub.
|
||||
- Review screen should show `LLM providers: Skipped`, not `Not configured`, when `session.llm` exists with an empty provider list.
|
||||
- Keep blank/no-key "Continue" validation unchanged: users cannot accidentally continue with zero providers unless they press the skip action.
|
||||
- **CLI installer:** Add explicit skip behavior.
|
||||
- Interactive `fabro install`: before provider selection, ask whether to configure LLM providers now, defaulting to yes. Choosing no returns an empty LLM selection and continues to GitHub.
|
||||
- Non-interactive `fabro install`: add hidden `--skip-llm`. It is mutually exclusive with `--llm-provider`, `--llm-api-key-stdin`, and `--llm-api-key-env`.
|
||||
- Keep accidental missing LLM flags as validation errors unless `--skip-llm` is present.
|
||||
- Update non-interactive usage text to include a skip example.
|
||||
- **Generated clients/types:** After OpenAPI edit, regenerate/build the API surfaces used by Rust and web:
|
||||
- `cargo build -p fabro-api`
|
||||
- `cd lib/packages/fabro-api-client && bun run generate`
|
||||
|
||||
## Test Plan
|
||||
|
||||
- **Server API tests:** Add coverage that `PUT /install/llm` accepts an empty providers list, marks `llm` complete in `/install/session`, and returns `llm.providers: []`.
|
||||
- **Finish persistence tests:** Add a browser-install finish test with skipped LLMs that asserts:
|
||||
- `/install/finish` returns `202`
|
||||
- settings and runtime auth secrets are written
|
||||
- no LLM credential entries are written to the vault
|
||||
- GitHub secrets still persist normally
|
||||
- **Web tests:** Add/update tests for:
|
||||
- clicking "Skip LLM setup" calls `PUT /install/llm` with `providers: []` and navigates to GitHub
|
||||
- review screen renders skipped LLMs as `Skipped`
|
||||
- pressing Continue with no API keys still shows the existing validation error
|
||||
- **CLI tests:** Add/update unit and integration coverage for:
|
||||
- `--skip-llm` requires `--non-interactive`
|
||||
- `--skip-llm` conflicts with all LLM credential flags
|
||||
- non-interactive validation succeeds with `--skip-llm` plus required GitHub/config flags
|
||||
- hidden usage text includes `--skip-llm`
|
||||
- existing no-input non-interactive install still fails
|
||||
- **Verification commands:**
|
||||
- `cargo nextest run -p fabro-server -p fabro-cli -p fabro-install`
|
||||
- `cargo build -p fabro-api`
|
||||
- `cd apps/fabro-web && bun test`
|
||||
- `cd apps/fabro-web && bun run typecheck`
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Empty `providers` is the install API representation of an explicit LLM skip; `llm: null` still means the step is incomplete.
|
||||
- GitHub, server URL, object store, and sandbox setup remain required.
|
||||
- `fabro doctor` behavior is intentionally unchanged after a skipped LLM install.
|
||||
- No changes are made to workflow execution or model resolution beyond allowing install to finish without credentials.
|
||||
|
|
@ -1561,6 +1561,14 @@ pub(crate) struct InstallNonInteractiveArgs {
|
|||
#[arg(long, hide = true)]
|
||||
pub(crate) llm_api_key_env: Option<String>,
|
||||
|
||||
/// Skip LLM setup entirely; install completes with zero LLM credentials
|
||||
#[arg(
|
||||
long,
|
||||
hide = true,
|
||||
conflicts_with_all = ["llm_provider", "llm_api_key_stdin", "llm_api_key_env"]
|
||||
)]
|
||||
pub(crate) skip_llm: bool,
|
||||
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) github_strategy: Option<InstallGitHubStrategyArg>,
|
||||
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ impl InstallNonInteractiveArgs {
|
|||
self.llm_provider.is_some()
|
||||
|| self.llm_api_key_stdin
|
||||
|| self.llm_api_key_env.is_some()
|
||||
|| self.skip_llm
|
||||
|| self.github_strategy.is_some()
|
||||
|| self.github_owner.is_some()
|
||||
|| self.github_username.is_some()
|
||||
|
|
@ -196,6 +197,8 @@ impl InstallNonInteractiveArgs {
|
|||
Some("--llm-api-key-stdin")
|
||||
} else if self.llm_api_key_env.is_some() {
|
||||
Some("--llm-api-key-env")
|
||||
} else if self.skip_llm {
|
||||
Some("--skip-llm")
|
||||
} else if self.github_strategy.is_some() {
|
||||
Some("--github-strategy")
|
||||
} else if self.github_owner.is_some() {
|
||||
|
|
@ -236,10 +239,16 @@ Non-interactive usage:
|
|||
--github-strategy app \
|
||||
--github-owner personal
|
||||
|
||||
fabro install --non-interactive \
|
||||
--skip-llm \
|
||||
--github-strategy token \
|
||||
--github-username brynary
|
||||
|
||||
Hidden non-interactive flags:
|
||||
--llm-provider <PROVIDER>
|
||||
--llm-api-key-stdin
|
||||
--llm-api-key-env <ENV_VAR>
|
||||
--skip-llm
|
||||
--github-strategy <token|app>
|
||||
--github-owner <personal|org:SLUG>
|
||||
--github-username <USERNAME>
|
||||
|
|
@ -249,6 +258,8 @@ Hidden non-interactive flags:
|
|||
|
||||
Notes:
|
||||
- Only one API-key-based LLM provider is supported in non-interactive mode.
|
||||
- Pass --skip-llm to finish install without configuring any LLM provider;
|
||||
it cannot be combined with the --llm-provider or --llm-api-key-* flags.
|
||||
- GitHub App setup prints a local handoff URL and waits for the browser callback."#
|
||||
}
|
||||
|
||||
|
|
@ -354,6 +365,19 @@ impl InstallInputSource for InteractiveInstallInputSource {
|
|||
s: &Styles,
|
||||
printer: Printer,
|
||||
) -> Result<LlmInstallSelection> {
|
||||
let configure_llm =
|
||||
spawn_blocking(|| prompt_confirm("Configure LLM providers now?", true)).await??;
|
||||
if !configure_llm {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} Skipping LLM setup — configure providers later with `fabro provider login`",
|
||||
s.green.apply_to("✔")
|
||||
);
|
||||
return Ok(LlmInstallSelection {
|
||||
credentials: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut credentials = Vec::new();
|
||||
let mut configured_providers: Vec<Provider> = Vec::new();
|
||||
let mut openai_configured = false;
|
||||
|
|
@ -514,10 +538,14 @@ impl NonInteractiveInstallInputSource {
|
|||
bail!("{}", non_interactive_install_usage());
|
||||
}
|
||||
|
||||
anyhow::ensure!(
|
||||
args.scripted.llm_api_key_stdin ^ args.scripted.llm_api_key_env.is_some(),
|
||||
"non-interactive install requires exactly one of --llm-api-key-stdin or --llm-api-key-env"
|
||||
);
|
||||
// `--skip-llm` opts out of LLM setup entirely, so the API-key flags are
|
||||
// neither required nor allowed (clap enforces the conflict).
|
||||
if !args.scripted.skip_llm {
|
||||
anyhow::ensure!(
|
||||
args.scripted.llm_api_key_stdin ^ args.scripted.llm_api_key_env.is_some(),
|
||||
"non-interactive install requires exactly one of --llm-api-key-stdin or --llm-api-key-env"
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
!(args.scripted.overwrite_settings && args.scripted.keep_existing_settings),
|
||||
"--overwrite-settings and --keep-existing-settings cannot be used together"
|
||||
|
|
@ -529,10 +557,17 @@ impl NonInteractiveInstallInputSource {
|
|||
}
|
||||
|
||||
fn validate(&self, config_exists: bool) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
self.args.llm_provider.is_some(),
|
||||
"non-interactive install requires --llm-provider"
|
||||
);
|
||||
if !self.args.skip_llm && self.args.llm_provider.is_none() {
|
||||
// Only suggest --skip-llm when no LLM credential flag is present;
|
||||
// it conflicts with the credential flags, so suggesting it
|
||||
// alongside one would just send the caller into a conflict error.
|
||||
let has_api_key_flag =
|
||||
self.args.llm_api_key_stdin || self.args.llm_api_key_env.is_some();
|
||||
if has_api_key_flag {
|
||||
bail!("non-interactive install requires --llm-provider");
|
||||
}
|
||||
bail!("non-interactive install requires --llm-provider (or --skip-llm)");
|
||||
}
|
||||
|
||||
match self.args.github_strategy {
|
||||
Some(InstallGitHubStrategyArg::Token) => {
|
||||
|
|
@ -599,6 +634,11 @@ impl InstallInputSource for NonInteractiveInstallInputSource {
|
|||
s: &Styles,
|
||||
printer: Printer,
|
||||
) -> Result<LlmInstallSelection> {
|
||||
if self.args.skip_llm {
|
||||
return Ok(LlmInstallSelection {
|
||||
credentials: Vec::new(),
|
||||
});
|
||||
}
|
||||
let provider = self
|
||||
.args
|
||||
.llm_provider
|
||||
|
|
@ -3044,6 +3084,66 @@ root = "{}"
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_source_accepts_skip_llm_without_credential_flags() {
|
||||
let args = install_args(true, InstallNonInteractiveArgs {
|
||||
skip_llm: true,
|
||||
github_strategy: Some(InstallGitHubStrategyArg::Token),
|
||||
github_username: Some("brynary".to_string()),
|
||||
..InstallNonInteractiveArgs::default()
|
||||
});
|
||||
|
||||
// `--skip-llm` alone is enough scripted input; the API-key flags are
|
||||
// neither required nor allowed when skipping LLM setup.
|
||||
NonInteractiveInstallInputSource::new(&args)
|
||||
.unwrap()
|
||||
.expect("--skip-llm should be accepted as non-interactive input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_source_validate_allows_skip_llm_without_provider() {
|
||||
let source = NonInteractiveInstallInputSource {
|
||||
args: InstallNonInteractiveArgs {
|
||||
skip_llm: true,
|
||||
github_strategy: Some(InstallGitHubStrategyArg::Token),
|
||||
github_username: Some("brynary".to_string()),
|
||||
..InstallNonInteractiveArgs::default()
|
||||
},
|
||||
};
|
||||
|
||||
source.validate(false).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_interactive_source_skip_llm_collects_no_credentials() {
|
||||
let source = NonInteractiveInstallInputSource {
|
||||
args: InstallNonInteractiveArgs {
|
||||
skip_llm: true,
|
||||
github_strategy: Some(InstallGitHubStrategyArg::Token),
|
||||
github_username: Some("brynary".to_string()),
|
||||
..InstallNonInteractiveArgs::default()
|
||||
},
|
||||
};
|
||||
|
||||
let facts = InstallFacts {
|
||||
codex_detected: false,
|
||||
};
|
||||
let selection = source
|
||||
.collect_llm_selection(&facts, &Styles::detect_stderr(), Printer::Silent)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
selection.credentials.is_empty(),
|
||||
"--skip-llm should collect zero LLM credentials"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_install_usage_documents_skip_llm() {
|
||||
let usage = non_interactive_install_usage();
|
||||
assert!(usage.contains("--skip-llm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_source_rejects_missing_github_strategy() {
|
||||
let source = NonInteractiveInstallInputSource {
|
||||
|
|
|
|||
|
|
@ -180,6 +180,78 @@ fn hidden_non_interactive_args_require_non_interactive() {
|
|||
assert!(stderr.contains("requires --non-interactive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_llm_requires_non_interactive() {
|
||||
let context = test_context!();
|
||||
let output = context
|
||||
.command()
|
||||
.args(["install", "--skip-llm"])
|
||||
.output()
|
||||
.expect("command should run");
|
||||
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(stderr.contains("--skip-llm requires --non-interactive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_llm_conflicts_with_llm_credential_flags() {
|
||||
let context = test_context!();
|
||||
|
||||
let provider_conflict = context
|
||||
.command()
|
||||
.args([
|
||||
"install",
|
||||
"--non-interactive",
|
||||
"--skip-llm",
|
||||
"--llm-provider",
|
||||
"anthropic",
|
||||
])
|
||||
.output()
|
||||
.expect("command should run");
|
||||
assert!(!provider_conflict.status.success());
|
||||
let stderr = String::from_utf8(provider_conflict.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("--skip-llm") && stderr.contains("--llm-provider"),
|
||||
"expected a conflict error between --skip-llm and --llm-provider: {stderr}"
|
||||
);
|
||||
|
||||
let stdin_conflict = context
|
||||
.command()
|
||||
.args([
|
||||
"install",
|
||||
"--non-interactive",
|
||||
"--skip-llm",
|
||||
"--llm-api-key-stdin",
|
||||
])
|
||||
.output()
|
||||
.expect("command should run");
|
||||
assert!(!stdin_conflict.status.success());
|
||||
let stderr = String::from_utf8(stdin_conflict.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("--skip-llm") && stderr.contains("--llm-api-key-stdin"),
|
||||
"expected a conflict error between --skip-llm and --llm-api-key-stdin: {stderr}"
|
||||
);
|
||||
|
||||
let env_conflict = context
|
||||
.command()
|
||||
.args([
|
||||
"install",
|
||||
"--non-interactive",
|
||||
"--skip-llm",
|
||||
"--llm-api-key-env",
|
||||
"ANTHROPIC_API_KEY",
|
||||
])
|
||||
.output()
|
||||
.expect("command should run");
|
||||
assert!(!env_conflict.status.success());
|
||||
let stderr = String::from_utf8(env_conflict.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("--skip-llm") && stderr.contains("--llm-api-key-env"),
|
||||
"expected a conflict error between --skip-llm and --llm-api-key-env: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_requires_prior_install() {
|
||||
let context = test_context!();
|
||||
|
|
|
|||
|
|
@ -800,14 +800,9 @@ async fn put_install_llm(
|
|||
}
|
||||
observe_operator(&state, &headers);
|
||||
|
||||
if input.providers.is_empty() {
|
||||
return (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(serde_json::json!({ "error": "at least one LLM provider is required" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// An empty providers list is an explicit skip: the LLM step is recorded as
|
||||
// completed with zero credentials. `/install/finish` still requires the
|
||||
// step to be present, just not populated.
|
||||
for provider in &input.providers {
|
||||
if let Some(error) = unsupported_install_provider_error(provider.provider) {
|
||||
return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, error);
|
||||
|
|
|
|||
|
|
@ -162,6 +162,23 @@ async fn put_install_llm(app: &axum::Router, token: &str) {
|
|||
response_status(response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
|
||||
}
|
||||
|
||||
async fn put_install_llm_skipped(app: &axum::Router, token: &str) {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/llm")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"providers":[]}"#))
|
||||
.expect("skipped LLM install request should build"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response_status(response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
|
||||
}
|
||||
|
||||
async fn put_install_github_token(app: &axum::Router, token: &str, username: &str) {
|
||||
let response = app
|
||||
.clone()
|
||||
|
|
@ -884,6 +901,99 @@ async fn token_install_finish_persists_settings_env_and_vault() {
|
|||
assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_llm_accepts_empty_providers_as_explicit_skip() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
put_install_llm_skipped(&app, "test-install-token").await;
|
||||
|
||||
let session_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/install/session")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let session_body =
|
||||
response_json(session_response, StatusCode::OK, "GET /install/session").await;
|
||||
assert!(
|
||||
session_body["completed_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|value| value == "llm"),
|
||||
"skipped LLM step should still count as completed"
|
||||
);
|
||||
assert_eq!(
|
||||
session_body["llm"]["providers"],
|
||||
serde_json::json!([]),
|
||||
"skipped LLM step should expose an empty providers list"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_install_finish_with_skipped_llm_persists_no_llm_credentials() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("settings.toml");
|
||||
let app = build_install_router(InstallAppState::for_test_with_paths(
|
||||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
));
|
||||
put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
|
||||
put_install_object_store_local(&app, "test-install-token").await;
|
||||
put_install_sandbox_docker(&app, "test-install-token").await;
|
||||
put_install_llm_skipped(&app, "test-install-token").await;
|
||||
put_install_github_token(&app, "test-install-token", "brynary").await;
|
||||
|
||||
let finish_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/finish")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let finish_body = response_json(
|
||||
finish_response,
|
||||
StatusCode::ACCEPTED,
|
||||
"POST /install/finish",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(finish_body["status"], "completing");
|
||||
|
||||
let settings = std::fs::read_to_string(&config_path).unwrap();
|
||||
assert!(settings.contains("https://fabro.example.com"));
|
||||
assert!(settings.contains("strategy = \"token\""));
|
||||
|
||||
let server_env = std::fs::read_to_string(
|
||||
fabro_config::Storage::new(temp_dir.path())
|
||||
.runtime_directory()
|
||||
.env_path(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(server_env.contains("SESSION_SECRET="));
|
||||
assert!(server_env.contains("FABRO_DEV_TOKEN="));
|
||||
|
||||
let vault = Vault::load(fabro_config::Storage::new(temp_dir.path()).secrets_path()).unwrap();
|
||||
assert!(
|
||||
vault.credential_entries().is_empty(),
|
||||
"skipped LLM install should not write any credential vault entries"
|
||||
);
|
||||
assert_eq!(
|
||||
vault.get("GITHUB_TOKEN"),
|
||||
Some("ghp_test_token"),
|
||||
"GitHub secrets still persist when the LLM step is skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_install_finish_invokes_finish_hook_before_response_returns() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ export const InstallApiAxiosParamCreator = function (configuration?: Configurati
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Records the LLM providers and API keys chosen during the browser install. Requires the one-time install token.
|
||||
* Records the LLM providers and API keys chosen during the browser install. An empty `providers` list marks the LLM step as completed and explicitly skipped. Requires the one-time install token.
|
||||
* @summary Save install LLM settings
|
||||
* @param {InstallLlmProvidersInput} installLlmProvidersInput
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -586,7 +586,7 @@ export const InstallApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Records the LLM providers and API keys chosen during the browser install. Requires the one-time install token.
|
||||
* Records the LLM providers and API keys chosen during the browser install. An empty `providers` list marks the LLM step as completed and explicitly skipped. Requires the one-time install token.
|
||||
* @summary Save install LLM settings
|
||||
* @param {InstallLlmProvidersInput} installLlmProvidersInput
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -748,7 +748,7 @@ export const InstallApiFactory = function (configuration?: Configuration, basePa
|
|||
return localVarFp.putInstallGithubToken(installGithubTokenInput, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Records the LLM providers and API keys chosen during the browser install. Requires the one-time install token.
|
||||
* Records the LLM providers and API keys chosen during the browser install. An empty `providers` list marks the LLM step as completed and explicitly skipped. Requires the one-time install token.
|
||||
* @summary Save install LLM settings
|
||||
* @param {InstallLlmProvidersInput} installLlmProvidersInput
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -889,7 +889,7 @@ export class InstallApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Records the LLM providers and API keys chosen during the browser install. Requires the one-time install token.
|
||||
* Records the LLM providers and API keys chosen during the browser install. An empty `providers` list marks the LLM step as completed and explicitly skipped. Requires the one-time install token.
|
||||
* @summary Save install LLM settings
|
||||
* @param {InstallLlmProvidersInput} installLlmProvidersInput
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
|
|||
|
|
@ -18,9 +18,12 @@
|
|||
import type { InstallLlmProviderInput } from './install-llm-provider-input';
|
||||
|
||||
/**
|
||||
* LLM providers selected during browser install.
|
||||
* LLM providers selected during browser install. An empty `providers` list explicitly marks the LLM step as completed and skipped.
|
||||
*/
|
||||
export interface InstallLlmProvidersInput {
|
||||
/**
|
||||
* LLM providers to persist. An empty list records an explicit skip: the LLM step is marked complete with zero credentials.
|
||||
*/
|
||||
'providers': Array<InstallLlmProviderInput>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
import type { InstallLlmSummaryProvidersInner } from './install-llm-summary-providers-inner';
|
||||
|
||||
/**
|
||||
* Redacted summary of persisted LLM install choices.
|
||||
* Redacted summary of persisted LLM install choices. Present with an empty `providers` list when the LLM step was explicitly skipped; `null` on the install session means the step is still incomplete.
|
||||
*/
|
||||
export interface InstallLlmSummary {
|
||||
'providers'?: Array<InstallLlmSummaryProvidersInner>;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue