mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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.
This commit is contained in:
parent
49ef284ed5
commit
ecdfdd82d8
390 changed files with 7088 additions and 2876 deletions
15
Cargo.lock
generated
15
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
37
apps/fabro-web/app/install-api.test.ts
Normal file
37
apps/fabro-web/app/install-api.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
208
apps/fabro-web/app/install-api.ts
Normal file
208
apps/fabro-web/app/install-api.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<Response> {
|
||||
return fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function readInstallError(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<string> {
|
||||
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<InstallSessionResponse> {
|
||||
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<InstallSessionResponse>;
|
||||
}
|
||||
|
||||
export async function testInstallLlm(
|
||||
token: string,
|
||||
provider: InstallLlmProviderInput,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<InstallGithubAppManifestResponse> {
|
||||
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<InstallGithubAppManifestResponse>;
|
||||
}
|
||||
|
||||
export async function finishInstall(token: string): Promise<InstallFinishResponse> {
|
||||
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<InstallFinishResponse>;
|
||||
}
|
||||
1121
apps/fabro-web/app/install-app.tsx
Normal file
1121
apps/fabro-web/app/install-app.tsx
Normal file
File diff suppressed because it is too large
Load diff
18
apps/fabro-web/app/install-router.tsx
Normal file
18
apps/fabro-web/app/install-router.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { RouteObject } from "react-router";
|
||||
|
||||
import Root, { ErrorBoundary as RootErrorBoundary } from "./root";
|
||||
import InstallApp from "./install-app";
|
||||
|
||||
export const installRoutes: RouteObject[] = [
|
||||
{
|
||||
path: "/",
|
||||
Component: Root,
|
||||
ErrorBoundary: RootErrorBoundary,
|
||||
children: [
|
||||
{
|
||||
path: "*",
|
||||
Component: InstallApp,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
31
apps/fabro-web/app/mode.test.ts
Normal file
31
apps/fabro-web/app/mode.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { consumeInstallTokenFromUrl, resolveFabroMode } from "./mode";
|
||||
|
||||
describe("resolveFabroMode", () => {
|
||||
test("returns install only for the explicit install marker", () => {
|
||||
expect(resolveFabroMode("install")).toBe("install");
|
||||
expect(resolveFabroMode("normal")).toBe("normal");
|
||||
expect(resolveFabroMode(undefined)).toBe("normal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeInstallTokenFromUrl", () => {
|
||||
test("extracts the install token and preserves other query params", () => {
|
||||
expect(
|
||||
consumeInstallTokenFromUrl("https://fabro.example.com/install?token=abc123&step=welcome"),
|
||||
).toEqual({
|
||||
token: "abc123",
|
||||
sanitizedUrl: "https://fabro.example.com/install?step=welcome",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns the original url when no install token is present", () => {
|
||||
expect(
|
||||
consumeInstallTokenFromUrl("https://fabro.example.com/install?step=welcome"),
|
||||
).toEqual({
|
||||
token: null,
|
||||
sanitizedUrl: "https://fabro.example.com/install?step=welcome",
|
||||
});
|
||||
});
|
||||
});
|
||||
22
apps/fabro-web/app/mode.ts
Normal file
22
apps/fabro-web/app/mode.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export type FabroMode = "normal" | "install";
|
||||
|
||||
export function resolveFabroMode(value: unknown): FabroMode {
|
||||
return value === "install" ? "install" : "normal";
|
||||
}
|
||||
|
||||
export function consumeInstallTokenFromUrl(url: string): {
|
||||
token: string | null;
|
||||
sanitizedUrl: string;
|
||||
} {
|
||||
const parsed = new URL(url);
|
||||
const token = parsed.searchParams.get("token");
|
||||
if (!token) {
|
||||
return { token: null, sanitizedUrl: parsed.toString() };
|
||||
}
|
||||
|
||||
parsed.searchParams.delete("token");
|
||||
return {
|
||||
token,
|
||||
sanitizedUrl: parsed.toString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -23,7 +23,6 @@ async function buildOnce() {
|
|||
minify: true,
|
||||
splitting: true,
|
||||
target: "browser",
|
||||
sourcemap: "external",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ services:
|
|||
fabro:
|
||||
image: ghcr.io/fabro-sh/fabro:nightly
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "32276:32276"
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ set -eu
|
|||
# When started as root (the default), ensure the storage volume is writable
|
||||
# by the unprivileged fabro user, then drop privileges.
|
||||
if [ "$(id -u)" = 0 ]; then
|
||||
mkdir -p "${FABRO_HOME:-/storage/.home}"
|
||||
chown fabro:fabro /storage
|
||||
chown -R fabro:fabro "${FABRO_HOME:-/storage/.home}"
|
||||
exec su-exec fabro "$@"
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
[server.storage]
|
||||
root = "/storage"
|
||||
|
|
@ -7,6 +7,8 @@ info:
|
|||
tags:
|
||||
- name: Discovery
|
||||
description: API discovery and health
|
||||
- name: Install
|
||||
description: First-run browser install workflow
|
||||
- name: Runs
|
||||
description: Run management operations
|
||||
- name: Human-in-the-Loop
|
||||
|
|
@ -67,6 +69,282 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/HealthResponse"
|
||||
|
||||
/install/session:
|
||||
get:
|
||||
operationId: getInstallSession
|
||||
tags: [Install]
|
||||
summary: Get install session
|
||||
description: >
|
||||
Returns the current browser-install session snapshot. Requires the one-time
|
||||
install token in `Authorization: Bearer`, `?token=`, or `X-Install-Token`.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: Current install session state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallSessionResponse"
|
||||
"401":
|
||||
description: Invalid or missing install token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/llm/test:
|
||||
post:
|
||||
operationId: testInstallLlmCredentials
|
||||
tags: [Install]
|
||||
summary: Validate install LLM credentials
|
||||
description: Validates an LLM API key without persisting it. Requires the one-time install token.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallLlmTestInput"
|
||||
responses:
|
||||
"200":
|
||||
description: Credentials validated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallLlmValidationResponse"
|
||||
"401":
|
||||
description: Invalid or missing install token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: Credential validation failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/llm:
|
||||
put:
|
||||
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.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallLlmProvidersInput"
|
||||
responses:
|
||||
"204":
|
||||
description: LLM settings recorded
|
||||
"401":
|
||||
description: Invalid or missing install token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: Invalid install input
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/server:
|
||||
put:
|
||||
operationId: putInstallServer
|
||||
tags: [Install]
|
||||
summary: Save install server configuration
|
||||
description: Records the canonical server URL confirmed by the operator. Requires the one-time install token.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallServerConfigInput"
|
||||
responses:
|
||||
"204":
|
||||
description: Server configuration recorded
|
||||
"401":
|
||||
description: Invalid or missing install token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: Invalid canonical URL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/github/token/test:
|
||||
post:
|
||||
operationId: testInstallGithubToken
|
||||
tags: [Install]
|
||||
summary: Validate install GitHub token
|
||||
description: Validates a GitHub personal access token without persisting it. Requires the one-time install token.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallGithubTokenTestInput"
|
||||
responses:
|
||||
"200":
|
||||
description: GitHub token validated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallGithubTokenTestResponse"
|
||||
"401":
|
||||
description: Invalid or missing install token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: GitHub token validation failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/github/token:
|
||||
put:
|
||||
operationId: putInstallGithubToken
|
||||
tags: [Install]
|
||||
summary: Save install GitHub token
|
||||
description: Records the GitHub personal access token chosen during the browser install. Requires the one-time install token.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallGithubTokenInput"
|
||||
responses:
|
||||
"204":
|
||||
description: GitHub token recorded
|
||||
"401":
|
||||
description: Invalid or missing install token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: Invalid install input
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/github/app/manifest:
|
||||
post:
|
||||
operationId: createInstallGithubAppManifest
|
||||
tags: [Install]
|
||||
summary: Build install GitHub App manifest
|
||||
description: Builds the GitHub App manifest and stores the temporary callback state for the browser install. Requires the one-time install token.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallGithubAppManifestInput"
|
||||
responses:
|
||||
"200":
|
||||
description: GitHub App manifest ready for browser handoff
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallGithubAppManifestResponse"
|
||||
"401":
|
||||
description: Invalid or missing install token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: Invalid install input or missing prior steps
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/github/app/redirect:
|
||||
get:
|
||||
operationId: completeInstallGithubAppRedirect
|
||||
tags: [Install]
|
||||
summary: Complete install GitHub App redirect
|
||||
description: Manifest-conversion callback target used by GitHub during browser install. Authorized by the callback `state` query parameter rather than the install token.
|
||||
security: []
|
||||
parameters:
|
||||
- name: code
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: state
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"302":
|
||||
description: Browser redirected back into the install SPA
|
||||
"400":
|
||||
description: Invalid or expired GitHub App callback state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"502":
|
||||
description: GitHub manifest conversion failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/install/finish:
|
||||
post:
|
||||
operationId: finishInstall
|
||||
tags: [Install]
|
||||
summary: Finalize browser install
|
||||
description: Persists settings, runtime secrets, and install outputs, then schedules the install-mode process to exit cleanly. Requires the one-time install token.
|
||||
security: []
|
||||
responses:
|
||||
"202":
|
||||
description: Install persisted successfully; restart handoff in progress
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstallFinishResponse"
|
||||
"401":
|
||||
description: Invalid or missing install token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: Install session is incomplete
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"500":
|
||||
description: Install persistence failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/health/diagnostics:
|
||||
post:
|
||||
operationId: runDiagnostics
|
||||
|
|
@ -1682,6 +1960,225 @@ components:
|
|||
example: basic
|
||||
|
||||
schemas:
|
||||
InstallSessionResponse:
|
||||
description: Current browser-install session snapshot with secrets redacted.
|
||||
type: object
|
||||
required:
|
||||
- completed_steps
|
||||
- prefill
|
||||
properties:
|
||||
completed_steps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
llm:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/InstallLlmSummary"
|
||||
- type: "null"
|
||||
server:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/InstallServerConfigInput"
|
||||
- type: "null"
|
||||
github:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/InstallGithubSummary"
|
||||
- type: "null"
|
||||
prefill:
|
||||
$ref: "#/components/schemas/InstallPrefill"
|
||||
|
||||
InstallPrefill:
|
||||
description: Server-detected defaults used to prefill the browser install wizard.
|
||||
type: object
|
||||
required:
|
||||
- canonical_url
|
||||
properties:
|
||||
canonical_url:
|
||||
type: string
|
||||
format: uri
|
||||
|
||||
InstallLlmValidationResponse:
|
||||
description: Successful response from install-time LLM credential validation.
|
||||
type: object
|
||||
required:
|
||||
- ok
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
InstallLlmTestInput:
|
||||
description: Input for install-time LLM credential validation.
|
||||
type: object
|
||||
required:
|
||||
- provider
|
||||
- api_key
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
example: anthropic
|
||||
api_key:
|
||||
type: string
|
||||
openai_base_url:
|
||||
type: string
|
||||
format: uri
|
||||
description: Optional override base URL used for OpenAI-style providers during install.
|
||||
|
||||
InstallLlmProvidersInput:
|
||||
description: LLM providers selected during browser install.
|
||||
type: object
|
||||
required:
|
||||
- providers
|
||||
properties:
|
||||
providers:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/components/schemas/InstallLlmProviderInput"
|
||||
|
||||
InstallLlmProviderInput:
|
||||
description: One persisted LLM provider configuration collected during browser install.
|
||||
type: object
|
||||
required:
|
||||
- provider
|
||||
- api_key
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
example: anthropic
|
||||
api_key:
|
||||
type: string
|
||||
openai_base_url:
|
||||
type: string
|
||||
format: uri
|
||||
|
||||
InstallLlmSummary:
|
||||
description: Redacted summary of persisted LLM install choices.
|
||||
type: object
|
||||
properties:
|
||||
providers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- provider
|
||||
- configured
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
configured:
|
||||
type: boolean
|
||||
openai_base_url:
|
||||
type: string
|
||||
format: uri
|
||||
|
||||
InstallServerConfigInput:
|
||||
description: Canonical server URL confirmed during browser install.
|
||||
type: object
|
||||
required:
|
||||
- canonical_url
|
||||
properties:
|
||||
canonical_url:
|
||||
type: string
|
||||
format: uri
|
||||
|
||||
InstallGithubTokenTestInput:
|
||||
description: Input for install-time GitHub token validation.
|
||||
type: object
|
||||
required:
|
||||
- token
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
|
||||
InstallGithubTokenTestResponse:
|
||||
description: Successful response from install-time GitHub token validation.
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
|
||||
InstallGithubTokenInput:
|
||||
description: GitHub personal access token chosen during browser install.
|
||||
type: object
|
||||
required:
|
||||
- token
|
||||
- username
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
|
||||
InstallGithubAppManifestInput:
|
||||
description: Input required to build the browser-install GitHub App manifest.
|
||||
type: object
|
||||
required:
|
||||
- owner
|
||||
- app_name
|
||||
- allowed_username
|
||||
properties:
|
||||
owner:
|
||||
type: string
|
||||
description: >
|
||||
Either `personal` or `org:<slug>`.
|
||||
app_name:
|
||||
type: string
|
||||
allowed_username:
|
||||
type: string
|
||||
|
||||
InstallGithubAppManifestResponse:
|
||||
description: Browser handoff payload for the GitHub App creation flow.
|
||||
type: object
|
||||
required:
|
||||
- manifest
|
||||
- github_form_action
|
||||
properties:
|
||||
manifest:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
github_form_action:
|
||||
type: string
|
||||
format: uri
|
||||
|
||||
InstallGithubSummary:
|
||||
description: Redacted summary of the GitHub install strategy selected during browser install.
|
||||
type: object
|
||||
required:
|
||||
- strategy
|
||||
properties:
|
||||
strategy:
|
||||
type: string
|
||||
enum: [token, app]
|
||||
username:
|
||||
type: string
|
||||
owner:
|
||||
type: string
|
||||
app_name:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
allowed_username:
|
||||
type: string
|
||||
|
||||
InstallFinishResponse:
|
||||
description: Response returned after install outputs are persisted successfully.
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- restart_url
|
||||
- dev_token
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [completing]
|
||||
restart_url:
|
||||
type: string
|
||||
format: uri
|
||||
dev_token:
|
||||
type: string
|
||||
|
||||
# ── Pagination ───────────────────────────────────────────────────────
|
||||
|
||||
PaginationMeta:
|
||||
|
|
|
|||
|
|
@ -189,11 +189,10 @@ impl CredentialResolver {
|
|||
Provider::Anthropic => self.lookup_env_or_vault(vault, "ANTHROPIC_BASE_URL"),
|
||||
Provider::OpenAi => self.lookup_env_or_vault(vault, "OPENAI_BASE_URL"),
|
||||
Provider::Gemini => self.lookup_env_or_vault(vault, "GEMINI_BASE_URL"),
|
||||
Provider::Kimi
|
||||
| Provider::Zai
|
||||
| Provider::Minimax
|
||||
| Provider::Inception
|
||||
| Provider::OpenAiCompatible => None,
|
||||
Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => None,
|
||||
Provider::OpenAiCompatible => {
|
||||
self.lookup_env_or_vault(vault, "OPENAI_COMPATIBLE_BASE_URL")
|
||||
}
|
||||
};
|
||||
match &credential.details {
|
||||
AuthDetails::ApiKey { key } => ApiCredential {
|
||||
|
|
@ -320,7 +319,7 @@ fn credential_ids_for(provider: Provider, usage: CredentialUsage) -> &'static [&
|
|||
(Provider::Zai, _) => &["zai"],
|
||||
(Provider::Minimax, _) => &["minimax"],
|
||||
(Provider::Inception, _) => &["inception"],
|
||||
(Provider::OpenAiCompatible, _) => &[],
|
||||
(Provider::OpenAiCompatible, _) => &["openai_compatible"],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -475,6 +474,44 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_compatible_resolves_with_openai_base_url_from_vault() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_credential(
|
||||
&mut vault,
|
||||
"openai_compatible",
|
||||
&api_key_credential(Provider::OpenAiCompatible, "compat-key"),
|
||||
)
|
||||
.unwrap();
|
||||
vault
|
||||
.set(
|
||||
"OPENAI_COMPATIBLE_BASE_URL",
|
||||
"https://compat.example.com/v1",
|
||||
fabro_vault::SecretType::Environment,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
|
||||
let resolved = resolver
|
||||
.resolve(Provider::OpenAiCompatible, CredentialUsage::ApiRequest)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ResolvedCredential::Api(api) = resolved else {
|
||||
panic!("expected api credential");
|
||||
};
|
||||
assert_eq!(
|
||||
api.auth_header,
|
||||
ApiKeyHeader::Bearer("compat-key".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
api.base_url.as_deref(),
|
||||
Some("https://compat.example.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_codex_cli_credential_includes_login_command_and_account_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -7,9 +7,15 @@ pub(crate) mod stop;
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use fabro_config::user::{FABRO_CONFIG_ENV, active_settings_path, legacy_default_storage_root};
|
||||
use fabro_server::bind::{self, Bind, BindRequest};
|
||||
use fabro_server::install::{self, InstallAppState};
|
||||
use fabro_server::serve::{self, ServeArgs};
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
|
||||
use crate::args::{
|
||||
GlobalArgs, ServerCommand, ServerRestartArgs, ServerServeArgs, ServerStartArgs,
|
||||
|
|
@ -28,6 +34,26 @@ pub(crate) async fn dispatch(
|
|||
foreground,
|
||||
serve_args,
|
||||
}) => {
|
||||
if let Some(bootstrap) = maybe_install_bootstrap(
|
||||
serve_args.config.as_deref(),
|
||||
storage_dir.as_deref(),
|
||||
&serve_args,
|
||||
)? {
|
||||
if serve_args.no_web {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
"Warning: --no-web is ignored during install; will be respected on next start."
|
||||
);
|
||||
}
|
||||
if !foreground {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
"Warning: --foreground is implicit during install."
|
||||
);
|
||||
}
|
||||
return run_install_mode(bootstrap, printer).await;
|
||||
}
|
||||
|
||||
let settings = user_config::load_settings_with_config_and_storage_dir(
|
||||
serve_args.config.as_deref(),
|
||||
storage_dir.as_deref(),
|
||||
|
|
@ -61,6 +87,27 @@ pub(crate) async fn dispatch(
|
|||
foreground,
|
||||
serve_args,
|
||||
}) => {
|
||||
if let Some(bootstrap) = maybe_install_bootstrap(
|
||||
serve_args.config.as_deref(),
|
||||
storage_dir.as_deref(),
|
||||
&serve_args,
|
||||
)? {
|
||||
stop::stop_server(&bootstrap.storage_dir, Duration::from_secs(timeout)).await;
|
||||
if serve_args.no_web {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
"Warning: --no-web is ignored during install; will be respected on next start."
|
||||
);
|
||||
}
|
||||
if !foreground {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
"Warning: --foreground is implicit during install."
|
||||
);
|
||||
}
|
||||
return run_install_mode(bootstrap, printer).await;
|
||||
}
|
||||
|
||||
let settings = user_config::load_settings_with_config_and_storage_dir(
|
||||
serve_args.config.as_deref(),
|
||||
storage_dir.as_deref(),
|
||||
|
|
@ -118,3 +165,163 @@ pub(crate) async fn dispatch(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InstallBootstrap {
|
||||
bind_request: BindRequest,
|
||||
storage_dir: std::path::PathBuf,
|
||||
config_path: std::path::PathBuf,
|
||||
token: String,
|
||||
}
|
||||
|
||||
fn maybe_install_bootstrap(
|
||||
explicit_config: Option<&std::path::Path>,
|
||||
storage_dir: Option<&std::path::Path>,
|
||||
serve_args: &ServeArgs,
|
||||
) -> Result<Option<InstallBootstrap>> {
|
||||
if explicit_config.is_some() || std::env::var_os(FABRO_CONFIG_ENV).is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let config_path = active_settings_path(None);
|
||||
if config_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let bind_request = match serve_args.bind.as_deref() {
|
||||
Some(bind) => bind::parse_bind(bind)?,
|
||||
None => default_install_bind_request(),
|
||||
};
|
||||
|
||||
let storage_dir = storage_dir
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_else(|| legacy_default_storage_root().join("storage"));
|
||||
|
||||
Ok(Some(InstallBootstrap {
|
||||
bind_request,
|
||||
storage_dir,
|
||||
config_path,
|
||||
token: generate_install_token()?,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn run_install_mode(bootstrap: InstallBootstrap, printer: Printer) -> Result<()> {
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
let token = bootstrap.token.clone();
|
||||
let state = InstallAppState::new(
|
||||
bootstrap.token,
|
||||
&bootstrap.storage_dir,
|
||||
&bootstrap.config_path,
|
||||
);
|
||||
install::serve_install_command(bootstrap.bind_request, state, move |bind| {
|
||||
announce_install_mode(bind, &token, styles, printer);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn announce_install_mode(bind: &Bind, token: &str, styles: &Styles, printer: Printer) {
|
||||
fabro_util::printerr!(printer, "");
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} Fabro server is unconfigured — install mode active.",
|
||||
styles.bold.apply_to("⚒️")
|
||||
);
|
||||
fabro_util::printerr!(printer, "");
|
||||
match install_url_hint(bind, token) {
|
||||
Some(url) => {
|
||||
fabro_util::printerr!(printer, " Open this URL in your browser to finish setup:");
|
||||
fabro_util::printerr!(printer, " {url}");
|
||||
}
|
||||
None => {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" Open the server root through your configured reverse proxy to finish setup."
|
||||
);
|
||||
}
|
||||
}
|
||||
fabro_util::printerr!(printer, "");
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
"{}",
|
||||
install_mode_next_step_message(running_in_container())
|
||||
);
|
||||
fabro_util::printerr!(printer, "");
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" Or visit the root path for the install token instructions."
|
||||
);
|
||||
fabro_util::printerr!(printer, "");
|
||||
}
|
||||
|
||||
fn install_mode_next_step_message(supervised: bool) -> &'static str {
|
||||
if supervised {
|
||||
" After install, the server should restart automatically."
|
||||
} else {
|
||||
" After install, you'll be prompted to re-run `fabro server start`."
|
||||
}
|
||||
}
|
||||
|
||||
fn install_url_hint(bind: &Bind, token: &str) -> Option<String> {
|
||||
if let Some(domain) = std::env::var("RAILWAY_PUBLIC_DOMAIN")
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(format!("https://{domain}/install?token={token}"));
|
||||
}
|
||||
|
||||
match bind {
|
||||
Bind::Tcp(addr) => Some(format!("http://{addr}/install?token={token}")),
|
||||
Bind::Unix(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_install_bind_request() -> BindRequest {
|
||||
if running_in_container() {
|
||||
BindRequest::Tcp(std::net::SocketAddr::from((
|
||||
[0, 0, 0, 0],
|
||||
serve::DEFAULT_TCP_PORT,
|
||||
)))
|
||||
} else {
|
||||
BindRequest::Tcp(std::net::SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
serve::DEFAULT_TCP_PORT,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn running_in_container() -> bool {
|
||||
std::env::var_os("RAILWAY_PUBLIC_DOMAIN").is_some()
|
||||
|| std::env::var_os("RAILWAY_ENVIRONMENT").is_some()
|
||||
|| std::env::var_os("KUBERNETES_SERVICE_HOST").is_some()
|
||||
|| std::path::Path::new("/.dockerenv").exists()
|
||||
|| std::path::Path::new("/run/.containerenv").exists()
|
||||
}
|
||||
|
||||
fn generate_install_token() -> Result<String> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
SystemRandom::new()
|
||||
.fill(&mut bytes)
|
||||
.map_err(|_| anyhow::anyhow!("failed to generate install token"))?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::install_mode_next_step_message;
|
||||
|
||||
#[test]
|
||||
fn install_mode_next_step_message_recommends_manual_restart_locally() {
|
||||
assert_eq!(
|
||||
install_mode_next_step_message(false),
|
||||
" After install, you'll be prompted to re-run `fabro server start`."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_mode_next_step_message_mentions_automatic_restart_in_supervised_envs() {
|
||||
assert_eq!(
|
||||
install_mode_next_step_message(true),
|
||||
" After install, the server should restart automatically."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::Utc;
|
||||
use fabro_config::user::load_settings_config;
|
||||
use fabro_config::user::{FABRO_CONFIG_ENV, default_settings_path, load_settings_config};
|
||||
use fabro_config::{Storage, envfile, resolve_server_from_file};
|
||||
use fabro_server::bind::{Bind, BindRequest};
|
||||
use fabro_server::jwt_auth::auth_method_name;
|
||||
|
|
@ -53,6 +53,11 @@ pub(crate) async fn ensure_server_running_for_storage(
|
|||
storage_dir: &Path,
|
||||
config_path: &Path,
|
||||
) -> Result<Bind> {
|
||||
ensure_storage_server_autostart_allowed(
|
||||
std::env::var_os(FABRO_CONFIG_ENV),
|
||||
config_path,
|
||||
&default_settings_path(),
|
||||
)?;
|
||||
ensure_server_running_with_bind(None, config_path, storage_dir).await
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +142,19 @@ async fn ensure_server_running_with_bind(
|
|||
}
|
||||
}
|
||||
|
||||
fn ensure_storage_server_autostart_allowed(
|
||||
config_env: Option<std::ffi::OsString>,
|
||||
config_path: &Path,
|
||||
default_settings_path: &Path,
|
||||
) -> Result<()> {
|
||||
if config_env.is_none() && config_path == default_settings_path && !config_path.exists() {
|
||||
bail!(
|
||||
"Cannot reach Fabro server: no settings.toml configured.\n\nRun one of:\n fabro server start # browser-based wizard\n fabro install # terminal wizard"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_matches_request(existing: &Bind, requested: &BindRequest) -> bool {
|
||||
match (existing, requested) {
|
||||
(Bind::Unix(existing_path), BindRequest::Unix(requested_path)) => {
|
||||
|
|
@ -549,3 +567,37 @@ fn read_log_tail(log_path: &Path, lines: usize) -> String {
|
|||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_util::Home;
|
||||
|
||||
use super::ensure_storage_server_autostart_allowed;
|
||||
|
||||
#[test]
|
||||
fn ensure_server_running_for_storage_errors_when_install_mode_is_required() {
|
||||
let temp_home = tempfile::tempdir().unwrap();
|
||||
let default_settings_path = Home::new(temp_home.path()).user_config();
|
||||
let result = ensure_storage_server_autostart_allowed(
|
||||
None,
|
||||
&default_settings_path,
|
||||
&default_settings_path,
|
||||
);
|
||||
|
||||
let err =
|
||||
result.expect_err("missing default settings.toml should not auto-start install mode");
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("Cannot reach Fabro server: no settings.toml configured."),
|
||||
"unexpected error: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("fabro server start"),
|
||||
"unexpected error: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("fabro install"),
|
||||
"unexpected error: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -285,11 +285,17 @@ fn host_is_local(host: &str) -> bool {
|
|||
}
|
||||
|
||||
fn load_dev_token_if_available(storage_dir: Option<&Path>) -> Option<String> {
|
||||
if let Some(token) = std::env::var("FABRO_DEV_TOKEN")
|
||||
.ok()
|
||||
.filter(|token| validate_dev_token_format(token))
|
||||
{
|
||||
return Some(token);
|
||||
let env_token = std::env::var("FABRO_DEV_TOKEN").ok();
|
||||
load_dev_token_if_available_from_sources(storage_dir, env_token.as_deref(), &Home::from_env())
|
||||
}
|
||||
|
||||
fn load_dev_token_if_available_from_sources(
|
||||
storage_dir: Option<&Path>,
|
||||
env_token: Option<&str>,
|
||||
home: &Home,
|
||||
) -> Option<String> {
|
||||
if let Some(token) = env_token.filter(|token| validate_dev_token_format(token)) {
|
||||
return Some(token.to_owned());
|
||||
}
|
||||
|
||||
if let Some(storage_dir) = storage_dir {
|
||||
|
|
@ -308,7 +314,7 @@ fn load_dev_token_if_available(storage_dir: Option<&Path>) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
dev_token::read_dev_token_file(&Home::from_env().dev_token_path())
|
||||
dev_token::read_dev_token_file(&home.dev_token_path())
|
||||
}
|
||||
|
||||
async fn wait_for_local_dev_token(storage_dir: &Path) -> Result<String> {
|
||||
|
|
@ -1104,15 +1110,10 @@ fn non_zero_u64_from_usize(value: usize) -> Option<NonZeroU64> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use super::*;
|
||||
|
||||
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
#[test]
|
||||
fn load_dev_token_if_available_prefers_env() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let temp_home = tempfile::tempdir().unwrap();
|
||||
let token_path = temp_home.path().join("dev-token");
|
||||
std::fs::write(
|
||||
|
|
@ -1121,17 +1122,12 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
std::env::set_var("FABRO_HOME", temp_home.path());
|
||||
std::env::set_var(
|
||||
"FABRO_DEV_TOKEN",
|
||||
"fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
|
||||
let token = load_dev_token_if_available_from_sources(
|
||||
None,
|
||||
Some("fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"),
|
||||
&Home::new(temp_home.path()),
|
||||
);
|
||||
|
||||
let token = load_dev_token_if_available(None);
|
||||
|
||||
std::env::remove_var("FABRO_DEV_TOKEN");
|
||||
std::env::remove_var("FABRO_HOME");
|
||||
|
||||
assert_eq!(
|
||||
token.as_deref(),
|
||||
Some("fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd")
|
||||
|
|
@ -1140,24 +1136,18 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn load_dev_token_if_available_reads_file() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let temp_home = tempfile::tempdir().unwrap();
|
||||
let token = "fabro_dev_abababababababababababababababababababababababababababababababab";
|
||||
std::fs::write(temp_home.path().join("dev-token"), token).unwrap();
|
||||
|
||||
std::env::remove_var("FABRO_DEV_TOKEN");
|
||||
std::env::set_var("FABRO_HOME", temp_home.path());
|
||||
|
||||
let loaded = load_dev_token_if_available(None);
|
||||
|
||||
std::env::remove_var("FABRO_HOME");
|
||||
let loaded =
|
||||
load_dev_token_if_available_from_sources(None, None, &Home::new(temp_home.path()));
|
||||
|
||||
assert_eq!(loaded.as_deref(), Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_dev_token_if_available_reads_path_from_active_server_record() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let temp_home = tempfile::tempdir().unwrap();
|
||||
let storage = tempfile::tempdir().unwrap();
|
||||
let token_dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -1177,12 +1167,11 @@ mod tests {
|
|||
})
|
||||
.unwrap();
|
||||
|
||||
std::env::remove_var("FABRO_DEV_TOKEN");
|
||||
std::env::set_var("FABRO_HOME", temp_home.path());
|
||||
|
||||
let loaded = load_dev_token_if_available(Some(storage.path()));
|
||||
|
||||
std::env::remove_var("FABRO_HOME");
|
||||
let loaded = load_dev_token_if_available_from_sources(
|
||||
Some(storage.path()),
|
||||
None,
|
||||
&Home::new(temp_home.path()),
|
||||
);
|
||||
|
||||
assert_eq!(loaded.as_deref(), Some(token));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::process::Stdio;
|
|||
use std::sync::{Arc, Barrier};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use fabro_test::{apply_test_isolation, fabro_snapshot, test_context};
|
||||
|
||||
fn isolated_storage_dir() -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir_in("/tmp").unwrap();
|
||||
|
|
@ -107,6 +107,96 @@ fn start_already_running_exits_with_error() {
|
|||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "This integration test needs the real foreground process to verify install-mode startup behavior."
|
||||
)]
|
||||
fn start_without_settings_enters_install_mode_in_foreground() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let storage_root = isolated_storage_dir();
|
||||
let storage_dir = storage_root.path().join("storage");
|
||||
|
||||
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut cmd, home_dir.path());
|
||||
cmd.env("FABRO_STORAGE_DIR", &storage_dir)
|
||||
.args(["server", "start", "--bind", "127.0.0.1:0"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().expect("server start should spawn");
|
||||
std::thread::sleep(Duration::from_millis(750));
|
||||
assert!(
|
||||
child
|
||||
.try_wait()
|
||||
.expect("install-mode server should still be running")
|
||||
.is_none(),
|
||||
"install mode should run in the foreground instead of daemonizing"
|
||||
);
|
||||
|
||||
child.kill().expect("kill install-mode server");
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.expect("collect install-mode stderr");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
stderr.contains("install mode active"),
|
||||
"expected install mode banner, got: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("/install?token="),
|
||||
"expected install-mode URL with token, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "This sync integration test needs the real foreground process to verify install-mode startup warnings."
|
||||
)]
|
||||
fn start_without_settings_ignores_no_web_during_install() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let storage_root = isolated_storage_dir();
|
||||
let storage_dir = storage_root.path().join("storage");
|
||||
|
||||
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut cmd, home_dir.path());
|
||||
cmd.env("FABRO_STORAGE_DIR", &storage_dir)
|
||||
.args(["server", "start", "--no-web", "--bind", "127.0.0.1:0"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().expect("server start should spawn");
|
||||
std::thread::sleep(Duration::from_millis(750));
|
||||
assert!(
|
||||
child
|
||||
.try_wait()
|
||||
.expect("install-mode server should still be running")
|
||||
.is_none(),
|
||||
"install mode should keep running in the foreground"
|
||||
);
|
||||
|
||||
child.kill().expect("kill install-mode server");
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.expect("collect install-mode stderr");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
stderr.contains(
|
||||
"Warning: --no-web is ignored during install; will be respected on next start."
|
||||
),
|
||||
"expected --no-web warning, got: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("install mode active"),
|
||||
"expected install mode banner, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_without_bind_uses_home_socket_instead_of_storage_socket() {
|
||||
let context = test_context!();
|
||||
|
|
|
|||
|
|
@ -36,14 +36,24 @@ impl ProxyPolicy {
|
|||
}
|
||||
}
|
||||
|
||||
fn resolve(explicit: Option<Self>) -> Result<Self, HttpClientBuildError> {
|
||||
fn resolve_with_env_value(
|
||||
explicit: Option<Self>,
|
||||
env_value: Option<&str>,
|
||||
) -> Result<Self, HttpClientBuildError> {
|
||||
if let Some(policy) = explicit {
|
||||
return Ok(policy);
|
||||
}
|
||||
|
||||
match env_value {
|
||||
Some(value) => Self::parse(value),
|
||||
None => Ok(Self::System),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(explicit: Option<Self>) -> Result<Self, HttpClientBuildError> {
|
||||
match std::env::var(HTTP_PROXY_POLICY_ENV) {
|
||||
Ok(value) => Self::parse(&value),
|
||||
Err(std::env::VarError::NotPresent) => Ok(Self::System),
|
||||
Ok(value) => Self::resolve_with_env_value(explicit, Some(&value)),
|
||||
Err(std::env::VarError::NotPresent) => Self::resolve_with_env_value(explicit, None),
|
||||
Err(std::env::VarError::NotUnicode(value)) => Err(
|
||||
HttpClientBuildError::InvalidProxyPolicy(value.to_string_lossy().into_owned()),
|
||||
),
|
||||
|
|
@ -213,47 +223,25 @@ pub fn blocking_test_http_client() -> Result<BlockingHttpClient, HttpClientBuild
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct EnvGuard {
|
||||
key: &'static str,
|
||||
original: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(key: &'static str, value: Option<&str>) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
match value {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.original.as_ref() {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_policy_defaults_to_system() {
|
||||
let _guard = EnvGuard::set(HTTP_PROXY_POLICY_ENV, None);
|
||||
assert_eq!(ProxyPolicy::resolve(None).unwrap(), ProxyPolicy::System);
|
||||
assert_eq!(
|
||||
ProxyPolicy::resolve_with_env_value(None, None).unwrap(),
|
||||
ProxyPolicy::System
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_policy_reads_disabled_from_env() {
|
||||
let _guard = EnvGuard::set(HTTP_PROXY_POLICY_ENV, Some("disabled"));
|
||||
assert_eq!(ProxyPolicy::resolve(None).unwrap(), ProxyPolicy::Disabled);
|
||||
assert_eq!(
|
||||
ProxyPolicy::resolve_with_env_value(None, Some("disabled")).unwrap(),
|
||||
ProxyPolicy::Disabled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_policy_rejects_invalid_env_values() {
|
||||
let _guard = EnvGuard::set(HTTP_PROXY_POLICY_ENV, Some("bogus"));
|
||||
let error = ProxyPolicy::resolve(None).unwrap_err();
|
||||
let error = ProxyPolicy::resolve_with_env_value(None, Some("bogus")).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
|
|
@ -263,9 +251,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn explicit_proxy_policy_overrides_env() {
|
||||
let _guard = EnvGuard::set(HTTP_PROXY_POLICY_ENV, Some("system"));
|
||||
assert_eq!(
|
||||
ProxyPolicy::resolve(Some(ProxyPolicy::Disabled)).unwrap(),
|
||||
ProxyPolicy::resolve_with_env_value(Some(ProxyPolicy::Disabled), Some("system"))
|
||||
.unwrap(),
|
||||
ProxyPolicy::Disabled
|
||||
);
|
||||
}
|
||||
|
|
|
|||
22
lib/crates/fabro-install/Cargo.toml
Normal file
22
lib/crates/fabro-install/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "fabro-install"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "Shared install primitives for Fabro CLI and server flows"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
ring = "0.17"
|
||||
toml.workspace = true
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
fabro-vault = { path = "../fabro-vault" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
452
lib/crates/fabro-install/src/lib.rs
Normal file
452
lib/crates/fabro-install/src/lib.rs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use fabro_config::{Storage, envfile};
|
||||
use fabro_vault::{SecretType as VaultSecretType, Vault};
|
||||
use ring::rand::SystemRandom;
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair as _};
|
||||
|
||||
const ED25519_SPKI_PREFIX: [u8; 12] = [
|
||||
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
];
|
||||
const ED25519_PUBLIC_KEY_LEN: usize = 32;
|
||||
|
||||
pub struct PendingSettingsWrite<'a> {
|
||||
pub path: &'a Path,
|
||||
pub contents: &'a str,
|
||||
pub previous_contents: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VaultSecretWrite {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub secret_type: VaultSecretType,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
fn pem_encode(label: &str, bytes: &[u8]) -> String {
|
||||
let body = BASE64_STANDARD.encode(bytes);
|
||||
let mut pem = String::new();
|
||||
pem.push_str("-----BEGIN ");
|
||||
pem.push_str(label);
|
||||
pem.push_str("-----\n");
|
||||
for chunk in body.as_bytes().chunks(64) {
|
||||
pem.push_str(std::str::from_utf8(chunk).expect("base64 output should be valid UTF-8"));
|
||||
pem.push('\n');
|
||||
}
|
||||
pem.push_str("-----END ");
|
||||
pem.push_str(label);
|
||||
pem.push_str("-----\n");
|
||||
pem
|
||||
}
|
||||
|
||||
fn ed25519_public_key_spki(public_key: &[u8]) -> Result<Vec<u8>> {
|
||||
anyhow::ensure!(
|
||||
public_key.len() == ED25519_PUBLIC_KEY_LEN,
|
||||
"generated Ed25519 public key had unexpected length"
|
||||
);
|
||||
|
||||
let mut spki = Vec::with_capacity(ED25519_SPKI_PREFIX.len() + public_key.len());
|
||||
spki.extend_from_slice(&ED25519_SPKI_PREFIX);
|
||||
spki.extend_from_slice(public_key);
|
||||
Ok(spki)
|
||||
}
|
||||
|
||||
pub fn generate_jwt_keypair() -> Result<(String, String)> {
|
||||
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new())
|
||||
.map_err(|_| anyhow::anyhow!("failed to generate Ed25519 keypair"))?;
|
||||
let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("failed to parse generated Ed25519 keypair"))?;
|
||||
let public_der = ed25519_public_key_spki(keypair.public_key().as_ref())?;
|
||||
|
||||
Ok((
|
||||
pem_encode("PRIVATE KEY", pkcs8.as_ref()),
|
||||
pem_encode("PUBLIC KEY", &public_der),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn default_web_url() -> String {
|
||||
format!("http://127.0.0.1:32276")
|
||||
}
|
||||
|
||||
fn root_table_mut(doc: &mut toml::Value) -> Result<&mut toml::Table> {
|
||||
doc.as_table_mut()
|
||||
.context("settings.toml root is not a table")
|
||||
}
|
||||
|
||||
fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut toml::Table> {
|
||||
table
|
||||
.entry(key.to_string())
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::default()))
|
||||
.as_table_mut()
|
||||
.with_context(|| format!("settings.toml [{key}] is not a table"))
|
||||
}
|
||||
|
||||
fn github_integration_table(doc: &mut toml::Value) -> Result<&mut toml::Table> {
|
||||
let root = doc
|
||||
.as_table_mut()
|
||||
.context("settings.toml root is not a table")?;
|
||||
let server = root
|
||||
.entry("server")
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::default()));
|
||||
let server_table = server
|
||||
.as_table_mut()
|
||||
.context("settings.toml [server] is not a table")?;
|
||||
let integrations = server_table
|
||||
.entry("integrations")
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::default()));
|
||||
let integrations_table = integrations
|
||||
.as_table_mut()
|
||||
.context("settings.toml [server.integrations] is not a table")?;
|
||||
let github = integrations_table
|
||||
.entry("github")
|
||||
.or_insert_with(|| toml::Value::Table(toml::Table::default()));
|
||||
github
|
||||
.as_table_mut()
|
||||
.context("settings.toml [server.integrations.github] is not a table")
|
||||
}
|
||||
|
||||
pub fn merge_server_settings(doc: &mut toml::Value, web_url: &str) -> Result<()> {
|
||||
let authority = web_url
|
||||
.split("://")
|
||||
.nth(1)
|
||||
.unwrap_or(web_url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or(web_url);
|
||||
|
||||
let root = root_table_mut(doc)?;
|
||||
root.insert("_version".to_string(), toml::Value::Integer(1));
|
||||
|
||||
let server = ensure_table(root, "server")?;
|
||||
|
||||
let api = ensure_table(server, "api")?;
|
||||
api.insert(
|
||||
"url".to_string(),
|
||||
toml::Value::String(format!("{web_url}/api/v1")),
|
||||
);
|
||||
|
||||
let listen = ensure_table(server, "listen")?;
|
||||
listen.insert("type".to_string(), toml::Value::String("tcp".to_string()));
|
||||
listen.insert(
|
||||
"address".to_string(),
|
||||
toml::Value::String(authority.to_string()),
|
||||
);
|
||||
|
||||
let web = ensure_table(server, "web")?;
|
||||
web.insert("enabled".to_string(), toml::Value::Boolean(true));
|
||||
web.insert("url".to_string(), toml::Value::String(web_url.to_string()));
|
||||
|
||||
let auth = ensure_table(server, "auth")?;
|
||||
auth.insert(
|
||||
"methods".to_string(),
|
||||
toml::Value::Array(vec![toml::Value::String("dev-token".to_string())]),
|
||||
);
|
||||
|
||||
let cli = ensure_table(root, "cli")?;
|
||||
let target = ensure_table(cli, "target")?;
|
||||
target.insert("type".to_string(), toml::Value::String("http".to_string()));
|
||||
target.insert("url".to_string(), toml::Value::String(web_url.to_string()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_token_settings(doc: &mut toml::Value) -> Result<()> {
|
||||
if let Some(server) = doc.get_mut("server").and_then(toml::Value::as_table_mut) {
|
||||
if let Some(auth) = server.get_mut("auth").and_then(toml::Value::as_table_mut) {
|
||||
if let Some(methods) = auth.get_mut("methods").and_then(toml::Value::as_array_mut) {
|
||||
methods.retain(|value| value.as_str() != Some("github"));
|
||||
if methods.is_empty() {
|
||||
methods.push(toml::Value::String("dev-token".to_string()));
|
||||
}
|
||||
}
|
||||
auth.remove("github");
|
||||
}
|
||||
}
|
||||
|
||||
let github = github_integration_table(doc)?;
|
||||
github.insert("strategy".into(), toml::Value::String("token".to_string()));
|
||||
github.remove("app_id");
|
||||
github.remove("slug");
|
||||
github.remove("client_id");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_github_app_settings(
|
||||
doc: &mut toml::Value,
|
||||
app_id: &str,
|
||||
slug: &str,
|
||||
client_id: &str,
|
||||
allowed_usernames: &[String],
|
||||
) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
!allowed_usernames.is_empty(),
|
||||
"GitHub App install requires at least one allowed GitHub username"
|
||||
);
|
||||
|
||||
let root = root_table_mut(doc)?;
|
||||
let server = ensure_table(root, "server")?;
|
||||
let auth = ensure_table(server, "auth")?;
|
||||
let methods = auth
|
||||
.entry("methods".to_string())
|
||||
.or_insert_with(|| toml::Value::Array(Vec::new()))
|
||||
.as_array_mut()
|
||||
.context("settings.toml [server.auth].methods is not an array")?;
|
||||
if !methods.iter().any(|value| value.as_str() == Some("github")) {
|
||||
methods.push(toml::Value::String("github".to_string()));
|
||||
}
|
||||
let github_auth = ensure_table(auth, "github")?;
|
||||
github_auth.insert(
|
||||
"allowed_usernames".to_string(),
|
||||
toml::Value::Array(
|
||||
allowed_usernames
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(toml::Value::String)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
let github = github_integration_table(doc)?;
|
||||
github.insert("strategy".into(), toml::Value::String("app".to_string()));
|
||||
github.insert("app_id".into(), toml::Value::String(app_id.to_string()));
|
||||
github.insert("slug".into(), toml::Value::String(slug.to_string()));
|
||||
github.insert(
|
||||
"client_id".into(),
|
||||
toml::Value::String(client_id.to_string()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_optional_file(path: &Path, previous_contents: Option<&str>) -> Result<()> {
|
||||
match previous_contents {
|
||||
Some(contents) => {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating directory {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(path, contents)
|
||||
.with_context(|| format!("restoring {}", path.display()))?;
|
||||
}
|
||||
None => match std::fs::remove_file(path) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => {
|
||||
return Err(anyhow::Error::new(err).context(format!("removing {}", path.display())));
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_server_env_secrets(storage_dir: &Path, secrets: &[(String, String)]) -> Result<()> {
|
||||
if secrets.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let env_path = Storage::new(storage_dir).server_state().env_path();
|
||||
envfile::merge_env_file(&env_path, secrets.iter().cloned())
|
||||
.with_context(|| format!("merging server env secrets into {}", env_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_vault_secrets_direct(storage_dir: &Path, secrets: &[VaultSecretWrite]) -> Result<()> {
|
||||
if secrets.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let vault_path = Storage::new(storage_dir).secrets_path();
|
||||
let mut vault = Vault::load(vault_path).map_err(anyhow::Error::from)?;
|
||||
for secret in secrets {
|
||||
vault
|
||||
.set(
|
||||
&secret.name,
|
||||
&secret.value,
|
||||
secret.secret_type,
|
||||
secret.description.as_deref(),
|
||||
)
|
||||
.map_err(anyhow::Error::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn persist_install_outputs_direct(
|
||||
storage_dir: &Path,
|
||||
server_env_secrets: &[(String, String)],
|
||||
vault_secrets: &[VaultSecretWrite],
|
||||
settings_write: Option<PendingSettingsWrite<'_>>,
|
||||
) -> Result<()> {
|
||||
persist_server_env_secrets(storage_dir, server_env_secrets)?;
|
||||
|
||||
if let Some(ref write) = settings_write {
|
||||
if let Some(parent) = write.path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating settings directory {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(write.path, write.contents)
|
||||
.with_context(|| format!("writing settings file {}", write.path.display()))?;
|
||||
}
|
||||
|
||||
let vault_path = Storage::new(storage_dir).secrets_path();
|
||||
let previous_vault = std::fs::read_to_string(&vault_path).ok();
|
||||
|
||||
if let Err(err) = persist_vault_secrets_direct(storage_dir, vault_secrets) {
|
||||
if let Some(ref write) = settings_write {
|
||||
restore_optional_file(write.path, write.previous_contents)?;
|
||||
}
|
||||
restore_optional_file(&vault_path, previous_vault.as_deref())?;
|
||||
return Err(err.context("persisting install outputs directly"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_config::Storage;
|
||||
use fabro_vault::{SecretType as VaultSecretType, Vault};
|
||||
|
||||
use super::{
|
||||
PendingSettingsWrite, VaultSecretWrite, default_web_url, merge_server_settings,
|
||||
persist_install_outputs_direct, write_github_app_settings,
|
||||
};
|
||||
|
||||
fn format_config_toml() -> String {
|
||||
let mut doc = toml::Value::Table(toml::Table::default());
|
||||
merge_server_settings(&mut doc, &default_web_url())
|
||||
.expect("default server config should be valid");
|
||||
toml::to_string_pretty(&doc).expect("default server config should serialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_has_auth_strategies() {
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
|
||||
let toml_str = format_config_toml();
|
||||
let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap();
|
||||
let auth = cfg
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|s| s.auth.as_ref())
|
||||
.expect("server.auth should be set");
|
||||
assert_eq!(
|
||||
auth.methods,
|
||||
Some(vec![fabro_types::settings::ServerAuthMethod::DevToken])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_server_settings_preserves_existing_top_level_sections() {
|
||||
let mut doc: toml::Value = toml::from_str(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[project]
|
||||
name = "custom"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
merge_server_settings(&mut doc, &default_web_url()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
doc.get("project")
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|project| project.get("name"))
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("custom")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_github_app_settings_uses_server_integrations_github() {
|
||||
let mut doc = toml::Value::Table(toml::Table::default());
|
||||
merge_server_settings(&mut doc, &default_web_url()).unwrap();
|
||||
|
||||
write_github_app_settings(&mut doc, "123", "fabro-app", "client-id", &[
|
||||
"brynary".to_string()
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let github = doc
|
||||
.get("server")
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|server| server.get("integrations"))
|
||||
.and_then(toml::Value::as_table)
|
||||
.and_then(|integrations| integrations.get("github"))
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("server.integrations.github should exist");
|
||||
|
||||
assert_eq!(
|
||||
github.get("strategy").and_then(toml::Value::as_str),
|
||||
Some("app")
|
||||
);
|
||||
assert_eq!(
|
||||
github.get("app_id").and_then(toml::Value::as_str),
|
||||
Some("123")
|
||||
);
|
||||
assert_eq!(
|
||||
github.get("slug").and_then(toml::Value::as_str),
|
||||
Some("fabro-app")
|
||||
);
|
||||
assert_eq!(
|
||||
github.get("client_id").and_then(toml::Value::as_str),
|
||||
Some("client-id")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_install_outputs_direct_restores_settings_and_vault_on_secret_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let storage = Storage::new(dir.path());
|
||||
let settings_path = dir.path().join("settings.toml");
|
||||
std::fs::write(&settings_path, "_version = 1\n[server]\n").unwrap();
|
||||
let vault_path = storage.secrets_path();
|
||||
let mut vault = Vault::load(vault_path.clone()).unwrap();
|
||||
vault
|
||||
.set(
|
||||
"EXISTING_SECRET",
|
||||
"keep",
|
||||
VaultSecretType::Environment,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = persist_install_outputs_direct(
|
||||
dir.path(),
|
||||
&[("SESSION_SECRET".to_string(), "session".to_string())],
|
||||
&[VaultSecretWrite {
|
||||
name: "bad-secret-name".to_string(),
|
||||
value: "boom".to_string(),
|
||||
secret_type: VaultSecretType::Environment,
|
||||
description: None,
|
||||
}],
|
||||
Some(PendingSettingsWrite {
|
||||
path: &settings_path,
|
||||
contents: "_version = 1\n[server]\nfoo = \"bar\"\n",
|
||||
previous_contents: Some("_version = 1\n[server]\n"),
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&settings_path).unwrap(),
|
||||
"_version = 1\n[server]\n"
|
||||
);
|
||||
|
||||
let restored = Vault::load(vault_path).unwrap();
|
||||
assert_eq!(restored.get("EXISTING_SECRET"), Some("keep"));
|
||||
assert_eq!(restored.get("bad-secret-name"), None);
|
||||
|
||||
let server_env =
|
||||
fabro_config::envfile::read_env_file(&storage.server_state().env_path()).unwrap();
|
||||
assert_eq!(
|
||||
server_env.get("SESSION_SECRET").map(String::as_str),
|
||||
Some("session")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
fabro-auth = { path = "../fabro-auth" }
|
||||
fabro-install = { path = "../fabro-install" }
|
||||
fabro-spa = { path = "../fabro-spa" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
|
|
|
|||
1266
lib/crates/fabro-server/src/install.rs
Normal file
1266
lib/crates/fabro-server/src/install.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ mod demo;
|
|||
pub mod diagnostics;
|
||||
pub mod error;
|
||||
pub mod github_webhooks;
|
||||
pub mod install;
|
||||
pub mod ip_allowlist;
|
||||
pub mod jwt_auth;
|
||||
mod run_manifest;
|
||||
|
|
|
|||
|
|
@ -380,6 +380,7 @@ where
|
|||
server_env_path,
|
||||
local_daemon_mode: true,
|
||||
env_lookup,
|
||||
http_client: None,
|
||||
})?;
|
||||
let reconciled = reconcile_incomplete_runs_on_startup(&state).await?;
|
||||
if reconciled > 0 {
|
||||
|
|
|
|||
|
|
@ -548,6 +548,7 @@ pub struct AppState {
|
|||
pub(crate) settings: Arc<RwLock<SettingsLayer>>,
|
||||
pub(crate) server_settings: RwLock<Arc<ResolvedServerSettings>>,
|
||||
pub(crate) local_daemon_mode: bool,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
shutting_down: AtomicBool,
|
||||
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
|
||||
slack_service: Option<Arc<SlackService>>,
|
||||
|
|
@ -564,6 +565,7 @@ pub(crate) struct AppStateConfig {
|
|||
pub(crate) server_env_path: PathBuf,
|
||||
pub(crate) local_daemon_mode: bool,
|
||||
pub(crate) env_lookup: EnvLookup,
|
||||
pub(crate) http_client: Option<fabro_http::HttpClient>,
|
||||
}
|
||||
|
||||
fn nonzero_i64(value: i64) -> Option<i64> {
|
||||
|
|
@ -619,6 +621,13 @@ impl AppState {
|
|||
)
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Result<fabro_http::HttpClient, fabro_http::HttpClientBuildError> {
|
||||
match &self.http_client {
|
||||
Some(client) => Ok(client.clone()),
|
||||
None => fabro_http::http_client(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn server_storage_dir(&self) -> PathBuf {
|
||||
PathBuf::from(
|
||||
resolve_interp_string(&self.server_settings().storage.root)
|
||||
|
|
@ -887,7 +896,7 @@ impl Default for RouterOptions {
|
|||
}
|
||||
|
||||
fn removed_web_route(path: &str) -> bool {
|
||||
matches!(path, "/setup/complete")
|
||||
matches!(path, "/setup/complete") || path.starts_with("/install")
|
||||
}
|
||||
|
||||
/// Build the axum Router with configurable web surface routing.
|
||||
|
|
@ -1860,7 +1869,7 @@ async fn get_github_repo(
|
|||
};
|
||||
|
||||
if client.is_none() {
|
||||
client = Some(match fabro_http::http_client() {
|
||||
client = Some(match state.http_client() {
|
||||
Ok(http) => http,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
|
||||
|
|
@ -1927,7 +1936,7 @@ async fn get_github_repo(
|
|||
|
||||
let client = match client {
|
||||
Some(client) => client,
|
||||
None => match fabro_http::http_client() {
|
||||
None => match state.http_client() {
|
||||
Ok(http) => http,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
|
||||
|
|
@ -2410,6 +2419,7 @@ pub(crate) fn create_test_app_state_with_session_key(
|
|||
server_env_path,
|
||||
local_daemon_mode,
|
||||
env_lookup,
|
||||
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
|
||||
})
|
||||
.expect("test app state should build")
|
||||
}
|
||||
|
|
@ -2444,6 +2454,7 @@ fn default_test_app_state_config(
|
|||
server_env_path,
|
||||
local_daemon_mode: false,
|
||||
env_lookup,
|
||||
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2492,6 +2503,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
server_env_path,
|
||||
local_daemon_mode,
|
||||
env_lookup,
|
||||
http_client,
|
||||
} = config;
|
||||
|
||||
let vault = Arc::new(AsyncRwLock::new(Vault::load(vault_path)?));
|
||||
|
|
@ -2556,6 +2568,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
settings,
|
||||
server_settings: RwLock::new(resolved_server_settings),
|
||||
local_daemon_mode,
|
||||
http_client,
|
||||
shutting_down: AtomicBool::new(false),
|
||||
registry_factory_override,
|
||||
slack_service,
|
||||
|
|
|
|||
|
|
@ -5,13 +5,27 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
|||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
pub fn serve(path: &str, headers: &HeaderMap) -> Response {
|
||||
serve_with_mode(path, headers, SpaMode::Normal)
|
||||
}
|
||||
|
||||
pub fn serve_install(path: &str, headers: &HeaderMap) -> Response {
|
||||
serve_with_mode(path, headers, SpaMode::Install)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum SpaMode {
|
||||
Normal,
|
||||
Install,
|
||||
}
|
||||
|
||||
fn serve_with_mode(path: &str, headers: &HeaderMap, mode: SpaMode) -> Response {
|
||||
let normalized = normalize(path);
|
||||
|
||||
if is_source_map(&normalized) {
|
||||
return (StatusCode::NOT_FOUND, "Static asset not found").into_response();
|
||||
}
|
||||
|
||||
if let Some(asset) = load_asset(&normalized) {
|
||||
if let Some(asset) = load_asset_for_mode(&normalized, mode) {
|
||||
return asset_response(&normalized, asset);
|
||||
}
|
||||
|
||||
|
|
@ -20,7 +34,7 @@ pub fn serve(path: &str, headers: &HeaderMap) -> Response {
|
|||
// `Accept: */*`, and similar non-HTML clients get a 404 so typos
|
||||
// don't silently return 25KB of UI shell.
|
||||
if accepts_html(headers) {
|
||||
if let Some(index) = load_asset("index.html") {
|
||||
if let Some(index) = load_asset_for_mode("index.html", mode) {
|
||||
return asset_response("index.html", index);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +75,29 @@ fn load_asset(path: &str) -> Option<Vec<u8>> {
|
|||
fabro_spa::get(path).map(fabro_spa::AssetBytes::into_vec)
|
||||
}
|
||||
|
||||
fn load_asset_for_mode(path: &str, mode: SpaMode) -> Option<Vec<u8>> {
|
||||
let asset = load_asset(path)?;
|
||||
if mode == SpaMode::Install && path == "index.html" {
|
||||
return Some(inject_install_mode(asset));
|
||||
}
|
||||
Some(asset)
|
||||
}
|
||||
|
||||
fn inject_install_mode(bytes: Vec<u8>) -> Vec<u8> {
|
||||
let Ok(html) = String::from_utf8(bytes.clone()) else {
|
||||
return bytes;
|
||||
};
|
||||
if html.contains("__FABRO_MODE__ = \"install\"") {
|
||||
return html.into_bytes();
|
||||
}
|
||||
|
||||
let injected = html.replace(
|
||||
"</head>",
|
||||
" <script>window.__FABRO_MODE__ = \"install\";</script>\n </head>",
|
||||
);
|
||||
injected.into_bytes()
|
||||
}
|
||||
|
||||
fn read_disk_asset(path: &str) -> Option<Vec<u8>> {
|
||||
read_disk_asset_from_root(&disk_asset_root(), path)
|
||||
}
|
||||
|
|
|
|||
763
lib/crates/fabro-server/tests/it/api/install.rs
Normal file
763
lib/crates/fabro-server/tests/it/api/install.rs
Normal file
|
|
@ -0,0 +1,763 @@
|
|||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use fabro_config::Storage;
|
||||
use fabro_model::Provider;
|
||||
use fabro_server::install::{InstallAppState, build_install_router};
|
||||
use fabro_vault::Vault;
|
||||
use httpmock::MockServer;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::helpers::body_json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_router_isolated_from_normal_api_surface() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let health_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(health_response.status(), StatusCode::OK);
|
||||
let health_body = body_json(health_response.into_body()).await;
|
||||
assert_eq!(health_body["status"], "ok");
|
||||
assert_eq!(health_body["mode"], "install");
|
||||
|
||||
let root_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/")
|
||||
.header("accept", "text/html,application/xhtml+xml")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(root_response.status(), StatusCode::OK);
|
||||
let root_html = String::from_utf8(
|
||||
axum::body::to_bytes(root_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
root_html.contains("__FABRO_MODE__ = \"install\""),
|
||||
"install shell should mark the SPA boot mode"
|
||||
);
|
||||
|
||||
let api_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/auth/me")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(api_response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_session_requires_valid_install_token() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let unauthorized = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/install/session")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let authorized = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/install/session")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("x-forwarded-proto", "https")
|
||||
.header("x-forwarded-host", "fabro.example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(authorized.status(), StatusCode::OK);
|
||||
let body = body_json(authorized.into_body()).await;
|
||||
assert_eq!(
|
||||
body["prefill"]["canonical_url"],
|
||||
"https://fabro.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_endpoints_reject_missing_and_wrong_tokens() {
|
||||
let app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
let cases = [
|
||||
("GET", "/install/session", None),
|
||||
(
|
||||
"POST",
|
||||
"/install/llm/test",
|
||||
Some(r#"{"provider":"anthropic","api_key":"anthropic-test-key"}"#),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
"/install/llm",
|
||||
Some(r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
"/install/server",
|
||||
Some(r#"{"canonical_url":"https://fabro.example.com"}"#),
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/install/github/token/test",
|
||||
Some(r#"{"token":"ghp_test_token"}"#),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
"/install/github/token",
|
||||
Some(r#"{"token":"ghp_test_token","username":"octocat"}"#),
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/install/github/app/manifest",
|
||||
Some(r#"{"owner":"personal","app_name":"Fabro","allowed_username":"octocat"}"#),
|
||||
),
|
||||
("POST", "/install/finish", None),
|
||||
];
|
||||
|
||||
for (method, path, body) in cases {
|
||||
let mut missing_token = Request::builder().method(method).uri(path);
|
||||
let mut wrong_token = Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("authorization", "Bearer wrong-token");
|
||||
if body.is_some() {
|
||||
missing_token = missing_token.header("content-type", "application/json");
|
||||
wrong_token = wrong_token.header("content-type", "application/json");
|
||||
}
|
||||
|
||||
let missing_token_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
missing_token
|
||||
.body(Body::from(body.unwrap_or_default().to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
missing_token_response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing token should be rejected for {method} {path}"
|
||||
);
|
||||
|
||||
let wrong_token_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
wrong_token
|
||||
.body(Body::from(body.unwrap_or_default().to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
wrong_token_response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"wrong token should be rejected for {method} {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_install_finish_persists_settings_env_and_vault() {
|
||||
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,
|
||||
));
|
||||
|
||||
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 github_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/github/token")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"token":"ghp_test_token","username":"brynary"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(github_response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
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["dev_token"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
|
||||
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())
|
||||
.server_state()
|
||||
.env_path(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(server_env.contains("FABRO_JWT_PRIVATE_KEY="));
|
||||
assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY="));
|
||||
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.get("anthropic").is_some());
|
||||
assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_validation_endpoints_validate_credentials_and_github_token() {
|
||||
let llm_mock = MockServer::start_async().await;
|
||||
llm_mock
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST").path("/v1/messages");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"id": "msg_test_123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"content": [{"type": "text", "text": "OK"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5}
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
let github_mock = MockServer::start_async().await;
|
||||
github_mock
|
||||
.mock_async(|when, then| {
|
||||
when.method("GET").path("/user");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"login":"octocat"}"#);
|
||||
})
|
||||
.await;
|
||||
|
||||
let app = build_install_router(
|
||||
InstallAppState::for_test("test-install-token")
|
||||
.with_provider_base_url(Provider::Anthropic, format!("{}/v1", llm_mock.url("")))
|
||||
.with_github_api_base_url(github_mock.url("")),
|
||||
);
|
||||
|
||||
let llm_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/llm/test")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"provider":"anthropic","api_key":"anthropic-test-key"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(llm_response.status(), StatusCode::OK);
|
||||
let llm_body = body_json(llm_response.into_body()).await;
|
||||
assert_eq!(llm_body["ok"], true);
|
||||
|
||||
let github_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/github/token/test")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"token":"ghp_test_token"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(github_response.status(), StatusCode::OK);
|
||||
let github_body = body_json(github_response.into_body()).await;
|
||||
assert_eq!(github_body["username"], "octocat");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_app_manifest_round_trip_updates_install_session() {
|
||||
let github_mock = MockServer::start_async().await;
|
||||
let conversion_mock = 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("test-install-token")
|
||||
.with_github_api_base_url(github_mock.url("")),
|
||||
);
|
||||
|
||||
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":"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;
|
||||
assert_eq!(
|
||||
manifest_body["github_form_action"],
|
||||
"https://github.com/settings/apps/new"
|
||||
);
|
||||
assert_eq!(
|
||||
manifest_body["manifest"]["callback_urls"][0],
|
||||
"https://fabro.example.com/auth/callback/github"
|
||||
);
|
||||
|
||||
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);
|
||||
assert_eq!(
|
||||
callback_response
|
||||
.headers()
|
||||
.get("location")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("/install/github/done?token=test-install-token")
|
||||
);
|
||||
conversion_mock.assert_async().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();
|
||||
assert_eq!(session_response.status(), StatusCode::OK);
|
||||
let session_body = body_json(session_response.into_body()).await;
|
||||
assert_eq!(session_body["github"]["strategy"], "app");
|
||||
assert_eq!(session_body["github"]["slug"], "fabro-test-app");
|
||||
assert_eq!(session_body["github"]["allowed_username"], "octocat");
|
||||
assert!(
|
||||
session_body["completed_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|value| value == "github")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_session() {
|
||||
let github_mock = MockServer::start_async().await;
|
||||
let conversion_mock = 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("test-install-token")
|
||||
.with_github_api_base_url(github_mock.url("")),
|
||||
);
|
||||
|
||||
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":"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 wrong_state_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/install/github/app/redirect?code=stub-code&state=wrong-state")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wrong_state_response.status(), StatusCode::BAD_REQUEST);
|
||||
conversion_mock.assert_calls_async(0).await;
|
||||
|
||||
let session_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/install/session")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(session_response.status(), StatusCode::OK);
|
||||
let session_body = body_json(session_response.into_body()).await;
|
||||
assert!(session_body["github"].is_null());
|
||||
assert!(
|
||||
!session_body["completed_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|value| value == "github")
|
||||
);
|
||||
|
||||
let missing_state_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/install/github/app/redirect?code=stub-code")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing_state_response.status(), StatusCode::BAD_REQUEST);
|
||||
conversion_mock.assert_calls_async(0).await;
|
||||
|
||||
let valid_state_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!(
|
||||
"/install/github/app/redirect?code=stub-code&state={state}"
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(valid_state_response.status(), StatusCode::FOUND);
|
||||
conversion_mock.assert_calls_async(1).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();
|
||||
|
||||
let storage = Storage::new(temp_dir.path());
|
||||
let vault_path = storage.secrets_path();
|
||||
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&vault_path, "{ not valid json").unwrap();
|
||||
|
||||
let app = build_install_router(InstallAppState::for_test_with_paths(
|
||||
"test-install-token",
|
||||
temp_dir.path(),
|
||||
&config_path,
|
||||
));
|
||||
|
||||
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 github_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/github/token")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"token":"ghp_test_token","username":"brynary"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(github_response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let finish_response = app
|
||||
.clone()
|
||||
.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::INTERNAL_SERVER_ERROR);
|
||||
let finish_body = body_json(finish_response.into_body()).await;
|
||||
assert!(
|
||||
finish_body["error"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("persisting install outputs directly"))
|
||||
);
|
||||
let leftover_env_keys = finish_body["leftover_env_keys"]
|
||||
.as_array()
|
||||
.expect("leftover_env_keys should be present");
|
||||
assert!(
|
||||
leftover_env_keys
|
||||
.iter()
|
||||
.any(|value| value == "SESSION_SECRET")
|
||||
);
|
||||
assert!(
|
||||
leftover_env_keys
|
||||
.iter()
|
||||
.any(|value| value == "FABRO_DEV_TOKEN")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&config_path).unwrap(),
|
||||
"_version = 1\n[project]\nname = \"keep\"\n"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&vault_path).unwrap(),
|
||||
"{ not valid json"
|
||||
);
|
||||
|
||||
let server_env = std::fs::read_to_string(storage.server_state().env_path()).unwrap();
|
||||
assert!(server_env.contains("SESSION_SECRET="));
|
||||
assert!(server_env.contains("FABRO_DEV_TOKEN="));
|
||||
|
||||
let session_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/install/session")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(session_response.status(), StatusCode::OK);
|
||||
let session_body = body_json(session_response.into_body()).await;
|
||||
assert!(
|
||||
session_body["completed_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|value| value == "github")
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod install;
|
||||
mod routing;
|
||||
mod runs;
|
||||
mod settings;
|
||||
|
|
|
|||
|
|
@ -65,6 +65,24 @@ async fn root_and_health_stay_at_root() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_routes_are_absent_in_normal_mode() {
|
||||
let app = build_router(create_app_state(), AuthMode::Disabled);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/install")
|
||||
.header("accept", "text/html,application/xhtml+xml")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moved_routes_not_at_root_of_api_prefix() {
|
||||
let app = build_router(create_app_state(), AuthMode::Disabled);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use fabro_server::install::{InstallAppState, build_install_router};
|
||||
use fabro_server::jwt_auth::AuthMode;
|
||||
use fabro_server::server::build_router;
|
||||
use serde_yaml::Value;
|
||||
|
|
@ -55,11 +56,34 @@ fn methods_for_path_item(item: &Value) -> Vec<Method> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn path_item_has_tag(item: &Value, expected: &str) -> bool {
|
||||
let Some(map) = item.as_mapping() else {
|
||||
return false;
|
||||
};
|
||||
map.values().any(|operation| {
|
||||
operation
|
||||
.get("tags")
|
||||
.and_then(Value::as_sequence)
|
||||
.is_some_and(|tags| tags.iter().any(|tag| tag.as_str() == Some(expected)))
|
||||
})
|
||||
}
|
||||
|
||||
fn request_for(method: &Method, uri: &str) -> Request<Body> {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
let body = if method == Method::POST || method == Method::PUT || method == Method::PATCH {
|
||||
builder = builder.header("content-type", "application/json");
|
||||
Body::from("{}")
|
||||
} else {
|
||||
Body::empty()
|
||||
};
|
||||
builder.body(body).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_spec_routes_are_routable() {
|
||||
let spec = load_spec();
|
||||
let state = test_app_state();
|
||||
let app = build_router(state, AuthMode::Disabled);
|
||||
let normal_app = build_router(test_app_state(), AuthMode::Disabled);
|
||||
let install_app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let paths = spec
|
||||
.get("paths")
|
||||
|
|
@ -70,18 +94,17 @@ async fn all_spec_routes_are_routable() {
|
|||
for (path_key, item) in paths {
|
||||
let path = path_key.as_str().expect("path key must be a string");
|
||||
let uri = resolve_path(path);
|
||||
let app = if path_item_has_tag(item, "Install") {
|
||||
install_app.clone()
|
||||
} else {
|
||||
normal_app.clone()
|
||||
};
|
||||
for method in methods_for_path_item(item) {
|
||||
let mut builder = Request::builder().method(&method).uri(&uri);
|
||||
|
||||
let body = if method == Method::POST {
|
||||
builder = builder.header("content-type", "application/json");
|
||||
Body::from("{}")
|
||||
} else {
|
||||
Body::empty()
|
||||
};
|
||||
|
||||
let req = builder.body(body).unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request_for(&method, &uri))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
response.status(),
|
||||
|
|
@ -95,6 +118,51 @@ async fn all_spec_routes_are_routable() {
|
|||
assert!(checked > 0, "No routes were checked — is the spec empty?");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_and_normal_routes_stay_isolated() {
|
||||
let spec = load_spec();
|
||||
let normal_app = build_router(test_app_state(), AuthMode::Disabled);
|
||||
let install_app = build_install_router(InstallAppState::for_test("test-install-token"));
|
||||
|
||||
let paths = spec
|
||||
.get("paths")
|
||||
.and_then(Value::as_mapping)
|
||||
.expect("spec is missing `paths`");
|
||||
|
||||
for (path_key, item) in paths {
|
||||
let path = path_key.as_str().expect("path key must be a string");
|
||||
let uri = resolve_path(path);
|
||||
let install_only = path_item_has_tag(item, "Install");
|
||||
let api_path = path.starts_with("/api/");
|
||||
|
||||
for method in methods_for_path_item(item) {
|
||||
if install_only {
|
||||
let response = normal_app
|
||||
.clone()
|
||||
.oneshot(request_for(&method, &uri))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"Install route {method} {path} should be absent from the normal router"
|
||||
);
|
||||
} else if api_path {
|
||||
let response = install_app
|
||||
.clone()
|
||||
.oneshot(request_for(&method, &uri))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"Normal API route {method} {path} should be absent from the install router"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: the earlier `server_settings_keys_match_openapi_spec` drift check
|
||||
// was deleted in Stage 6.3b alongside the legacy flat `fabro_types::Settings`
|
||||
// struct that it instantiated. The v2 `/api/v1/settings` and
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
use std::ffi::OsString;
|
||||
use std::sync::{LazyLock, Mutex, MutexGuard};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use httpmock::MockServer;
|
||||
|
|
@ -12,34 +9,6 @@ use crate::helpers::{
|
|||
test_app_with_no_providers, test_app_with_scheduler, test_settings, wait_for_run_status,
|
||||
};
|
||||
|
||||
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
struct ProxyPolicyGuard {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
|
||||
impl ProxyPolicyGuard {
|
||||
fn disabled() -> Self {
|
||||
let lock = ENV_LOCK.lock().unwrap();
|
||||
let previous = std::env::var_os(fabro_http::HTTP_PROXY_POLICY_ENV);
|
||||
std::env::set_var(fabro_http::HTTP_PROXY_POLICY_ENV, "disabled");
|
||||
Self {
|
||||
_lock: lock,
|
||||
previous,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProxyPolicyGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.previous.as_ref() {
|
||||
Some(value) => std::env::set_var(fabro_http::HTTP_PROXY_POLICY_ENV, value),
|
||||
None => std::env::remove_var(fabro_http::HTTP_PROXY_POLICY_ENV),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn completion_request(stream: bool) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
|
|
@ -153,7 +122,6 @@ async fn completion_no_provider_streaming_returns_502() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn completion_non_streaming_returns_valid_json() {
|
||||
let _proxy_guard = ProxyPolicyGuard::disabled();
|
||||
let mock_server = MockServer::start_async().await;
|
||||
mock_server
|
||||
.mock_async(|when, then| {
|
||||
|
|
@ -194,7 +162,6 @@ async fn completion_non_streaming_returns_valid_json() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn completion_streaming_returns_sse() {
|
||||
let _proxy_guard = ProxyPolicyGuard::disabled();
|
||||
let mock_server = MockServer::start_async().await;
|
||||
mock_server
|
||||
.mock_async(|when, then| {
|
||||
|
|
|
|||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
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
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
|
|
@ -1,3 +1 @@
|
|||
import{E as a}from"./chunk-k28qfdjw.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=B2B8C7571ED9C9F664756E2164756E21
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
import{O as a}from"./chunk-n1k68xa8.js";import"./chunk-pectm3zk.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=A6C25A91CA86528764756E2164756E21
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1 @@
|
|||
import{A as a}from"./chunk-3m9zvayn.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=2BADFA3D6582DF5E64756E2164756E21
|
||||
|
|
|
|||
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
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1 @@
|
|||
import{z as a}from"./chunk-mshhqkhw.js";import"./chunk-eew9p379.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=404DC8C86C76ABB464756E2164756E21
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
import{d as a}from"./chunk-wzb9zsp9.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=B3D290E5FB496EF464756E2164756E21
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1 @@
|
|||
import{u as a}from"./chunk-4v5fshv2.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=0D1B315872C84EA364756E2164756E21
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1 @@
|
|||
import{v as e}from"./chunk-8mhds0wh.js";import"./chunk-q07bg6gn.js";var t=Object.freeze(JSON.parse('{"displayName":"Git Commit Message","name":"git-commit","patterns":[{"begin":"(?=^diff --git)","contentName":"source.diff","end":"\\\\z","name":"meta.embedded.diff.git-commit","patterns":[{"include":"source.diff"}]},{"begin":"^(?!#)","end":"^(?=#)","name":"meta.scope.message.git-commit","patterns":[{"captures":{"1":{"name":"invalid.deprecated.line-too-long.git-commit"},"2":{"name":"invalid.illegal.line-too-long.git-commit"}},"match":"\\\\G.{0,50}(.{0,22}(.*))$","name":"meta.scope.subject.git-commit"}]},{"begin":"^(?=#)","contentName":"comment.line.number-sign.git-commit","end":"^(?!#)","name":"meta.scope.metadata.git-commit","patterns":[{"captures":{"1":{"name":"markup.changed.git-commit"}},"match":"^#\\\\t((modified|renamed):.*)$"},{"captures":{"1":{"name":"markup.inserted.git-commit"}},"match":"^#\\\\t(new file:.*)$"},{"captures":{"1":{"name":"markup.deleted.git-commit"}},"match":"^#\\\\t(deleted.*)$"},{"captures":{"1":{"name":"keyword.other.file-type.git-commit"},"2":{"name":"string.unquoted.filename.git-commit"}},"match":"^#\\\\t([^:]+): *(.*)$"}]}],"scopeName":"text.git-commit","embeddedLangs":["diff"]}')),i=[...e,t];export{i as default};
|
||||
|
||||
//# debugId=FF6F1DB5E1831DE564756E2164756E21
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
import"./chunk-q07bg6gn.js";var e=Object.freeze(JSON.parse('{"displayName":"Fluent","name":"fluent","patterns":[{"include":"#comment"},{"include":"#message"},{"include":"#wrong-line"}],"repository":{"attributes":{"begin":"\\\\s*(\\\\.[A-Za-z][-0-9A-Z_a-z]*\\\\s*=\\\\s*)","beginCaptures":{"1":{"name":"support.class.attribute-begin.fluent"}},"end":"^(?=\\\\s*[^.])","patterns":[{"include":"#placeable"}]},"comment":{"match":"^##?#?\\\\s.*$","name":"comment.fluent"},"function-comma":{"match":",","name":"support.function.function-comma.fluent"},"function-named-argument":{"begin":"([0-9A-Za-z]+:)\\\\s*([\\"0-9A-Za-z]+)","beginCaptures":{"1":{"name":"support.function.named-argument.name.fluent"},"2":{"name":"variable.other.named-argument.value.fluent"}},"end":"(?=[),\\\\s])","name":"variable.other.named-argument.fluent"},"function-positional-argument":{"match":"\\\\$[-0-9A-Z_a-z]+","name":"variable.other.function.positional-argument.fluent"},"invalid-placeable-string-missing-end-quote":{"match":"\\"[^\\"]+$","name":"invalid.illegal.wrong-placeable-missing-end-quote.fluent"},"invalid-placeable-wrong-placeable-missing-end":{"match":"([^A-Z}]*|[^-][^>])$\\\\b","name":"invalid.illegal.wrong-placeable-missing-end.fluent"},"message":{"begin":"^(-?[A-Za-z][-0-9A-Z_a-z]*\\\\s*=\\\\s*)","beginCaptures":{"1":{"name":"support.class.message-identifier.fluent"}},"contentName":"string.fluent","end":"^(?=\\\\S)","patterns":[{"include":"#attributes"},{"include":"#placeable"}]},"placeable":{"begin":"(\\\\{)","beginCaptures":{"1":{"name":"keyword.placeable.begin.fluent"}},"contentName":"variable.other.placeable.content.fluent","end":"(})","endCaptures":{"1":{"name":"keyword.placeable.end.fluent"}},"patterns":[{"include":"#placeable-string"},{"include":"#placeable-function"},{"include":"#placeable-reference-or-number"},{"include":"#selector"},{"include":"#invalid-placeable-wrong-placeable-missing-end"},{"include":"#invalid-placeable-string-missing-end-quote"},{"include":"#invalid-placeable-wrong-function-name"}]},"placeable-function":{"begin":"([A-Z][-0-9A-Z_]*\\\\()","beginCaptures":{"1":{"name":"support.function.placeable-function.call.begin.fluent"}},"contentName":"string.placeable-function.fluent","end":"(\\\\))","endCaptures":{"1":{"name":"support.function.placeable-function.call.end.fluent"}},"patterns":[{"include":"#function-comma"},{"include":"#function-positional-argument"},{"include":"#function-named-argument"}]},"placeable-reference-or-number":{"match":"(([-$])[-0-9A-Z_a-z]+|[A-Za-z][-0-9A-Z_a-z]*|[0-9]+)","name":"variable.other.placeable.reference-or-number.fluent"},"placeable-string":{"begin":"(\\")(?=[^\\\\n]*\\")","beginCaptures":{"1":{"name":"variable.other.placeable-string-begin.fluent"}},"contentName":"string.placeable-string-content.fluent","end":"(\\")","endCaptures":{"1":{"name":"variable.other.placeable-string-end.fluent"}}},"selector":{"begin":"(->)","beginCaptures":{"1":{"name":"support.function.selector.begin.fluent"}},"contentName":"string.selector.content.fluent","end":"^(?=\\\\s*})","patterns":[{"include":"#selector-item"}]},"selector-item":{"begin":"(\\\\s*\\\\*?\\\\[)([-0-9A-Z_a-z]+)(]\\\\s*)","beginCaptures":{"1":{"name":"support.function.selector-item.begin.fluent"},"2":{"name":"variable.other.selector-item.begin.fluent"},"3":{"name":"support.function.selector-item.begin.fluent"}},"contentName":"string.selector-item.content.fluent","end":"^(?=(\\\\s*})|(\\\\s*\\\\[)|(\\\\s*\\\\*))","patterns":[{"include":"#placeable"}]},"wrong-line":{"match":".*","name":"invalid.illegal.wrong-line.fluent"}},"scopeName":"source.ftl","aliases":["ftl"]}')),n=[e];export{n as default};
|
||||
|
||||
//# debugId=ECEE1DD8C17D275564756E2164756E21
|
||||
|
|
|
|||
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
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
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
|
|
@ -1,3 +1 @@
|
|||
import"./chunk-q07bg6gn.js";var e=Object.freeze(JSON.parse('{"displayName":"Logo","fileTypes":[],"name":"logo","patterns":[{"match":"^to [.\\\\w]+","name":"entity.name.function.logo"},{"match":"continue|do\\\\.until|do\\\\.while|end|for(each)?|if(else|falsetrue|)|repeat|stop|until","name":"keyword.control.logo"},{"match":"\\\\b(\\\\.defmacro|\\\\.eq|\\\\.macro|\\\\.maybeoutput|\\\\.setbf|\\\\.setfirst|\\\\.setitem|\\\\.setsegmentsize|allopen|allowgetset|and|apply|arc|arctan|arity|arrayp??|arraytolist|ascii|ashift|back|background|backslashedp|beforep|bitand|bitnot|bitor|bitxor|buriedp??|bury|buryall|buryname|butfirsts??|butlast|bye|cascade|case|caseignoredp|catch|char|clean|clearscreen|cleartext|close|closeall|combine|cond|contents|copydef|cos|count|crossmap|cursor|define|definedp|dequeue|difference|dribble|edall|edit|editfile|edns??|edpls??|edps|emptyp|eofp|epspict|equalp|erall|erase|erasefile|erns??|erpls??|erps|erract|error|exp|fence|filep|fill|filter|find|firsts??|forever|form|forward|fput|fullprintp|fullscreen|fulltext|gc|gensym|global|goto|gprop|greaterp|heading|help|hideturtle|home|ignore|int|invoke|iseq|item|keyp|label|last|left|lessp|listp??|listtoarray|ln|load|loadnoisily|loadpict|local|localmake|log10|lowercase|lput|lshift|macroexpand|macrop|make|map|map.se|mdarray|mditem|mdsetitem|memberp??|minus|modulo|name|namelist|namep|names|nodes|nodribble|norefresh|not|numberp|openappend|openread|openupdate|openwrite|or|output|palette|parse|pause|pen|pencolor|pendownp??|penerase|penmode|penpaint|penreverse|pensize|penup|pick|plistp??|plists|pllist|po|poall|pons??|popl??|popls|pops|pos|pots??|power|pprop|prefix|primitivep|print|printdepthlimit|printwidthlimit|procedurep|procedures|product|push|queue|quoted|quotient|radarctan|radcos|radsin|random|rawascii|readchars??|reader|readlist|readpos|readrawline|readword|redefp|reduce|refresh|remainder|remdup|remove|remprop|repcount|rerandom|reverse|right|round|rseq|run|runparse|runresult|savel??|savepict|screenmode|scrunch|sentence|setbackground|setcursor|seteditor|setheading|sethelploc|setitem|setlibloc|setmargins|setpalette|setpen|setpencolor|setpensize|setpos|setprefix|setread|setreadpos|setscrunch|settemploc|settextcolor|setwrite|setwritepos|setxy??|sety|shell|show|shownp|showturtle|sin|splitscreen|sqrt|standout|startup|step|steppedp??|substringp|sum|tag|test|text|textscreen|thing|throw|towards|traced??|tracedp|transfer|turtlemode|type|unbury|unburyall|unburyname|unburyonedit|unstep|untrace|uppercase|usealternatenam|wait|while|window|wordp??|wrap|writepos|writer|xcor|ycor)\\\\b","name":"keyword.other.logo"},{"captures":{"1":{"name":"punctuation.definition.variable.logo"}},"match":"(:)(?:\\\\|[^|]*\\\\||[-.\\\\w]*)+","name":"variable.parameter.logo"},{"match":"\\"(?:\\\\|[^|]*\\\\||[-.\\\\w]*)+","name":"string.other.word.logo"},{"begin":"(^[\\\\t ]+)?(?=;)","beginCaptures":{"1":{"name":"punctuation.whitespace.comment.leading.logo"}},"end":"(?!\\\\G)","patterns":[{"begin":";","beginCaptures":{"0":{"name":"punctuation.definition.comment.logo"}},"end":"\\\\n","name":"comment.line.semicolon.logo"}]}],"scopeName":"source.logo"}')),t=[e];export{t as default};
|
||||
|
||||
//# debugId=2ECDFE87FCB33F3664756E2164756E21
|
||||
|
|
|
|||
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
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1 @@
|
|||
import{p as a}from"./chunk-zcmpnmq4.js";import"./chunk-pectm3zk.js";import"./chunk-kgq5332v.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=A9845377EBA21FA764756E2164756E21
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1 @@
|
|||
import"./chunk-q07bg6gn.js";var n=Object.freeze(JSON.parse('{"displayName":"Jsonnet","name":"jsonnet","patterns":[{"include":"#expression"},{"include":"#keywords"}],"repository":{"builtin-functions":{"patterns":[{"match":"\\\\bstd\\\\.(acos|asin|atan|ceil|char|codepoint|cos|exp|exponent)\\\\b","name":"support.function.jsonnet"},{"match":"\\\\bstd\\\\.(filter|floor|force|length|log|makeArray|mantissa)\\\\b","name":"support.function.jsonnet"},{"match":"\\\\bstd\\\\.(objectFields|objectHas|pow|sin|sqrt|tan|type|thisFile)\\\\b","name":"support.function.jsonnet"},{"match":"\\\\bstd\\\\.(acos|asin|atan|ceil|char|codepoint|cos|exp|exponent)\\\\b","name":"support.function.jsonnet"},{"match":"\\\\bstd\\\\.(abs|assertEqual|escapeString(Bash|Dollars|Json|Python))\\\\b","name":"support.function.jsonnet"},{"match":"\\\\bstd\\\\.(filterMap|flattenArrays|foldl|foldr|format|join)\\\\b","name":"support.function.jsonnet"},{"match":"\\\\bstd\\\\.(lines|manifest(Ini|Python(Vars)?)|map|max|min|mod)\\\\b","name":"support.function.jsonnet"},{"match":"\\\\bstd\\\\.(s(?:et(Diff|Inter|Member|Union)??|ort))\\\\b","name":"support.function.jsonnet"},{"match":"\\\\bstd\\\\.(range|split|stringChars|substr|toString|uniq)\\\\b","name":"support.function.jsonnet"}]},"comment":{"patterns":[{"begin":"/\\\\*","end":"\\\\*/","name":"comment.block.jsonnet"},{"match":"//.*$","name":"comment.line.jsonnet"},{"match":"#.*$","name":"comment.block.jsonnet"}]},"double-quoted-strings":{"begin":"\\"","end":"\\"","name":"string.quoted.double.jsonnet","patterns":[{"match":"\\\\\\\\([\\"/\\\\\\\\bfnrt]|(u\\\\h{4}))","name":"constant.character.escape.jsonnet"},{"match":"\\\\\\\\[^\\"/\\\\\\\\bfnrtu]","name":"invalid.illegal.jsonnet"}]},"expression":{"patterns":[{"include":"#literals"},{"include":"#comment"},{"include":"#single-quoted-strings"},{"include":"#double-quoted-strings"},{"include":"#triple-quoted-strings"},{"include":"#builtin-functions"},{"include":"#functions"}]},"functions":{"patterns":[{"begin":"\\\\b([A-Z_a-z][0-9A-Z_a-z]*)\\\\s*\\\\(","beginCaptures":{"1":{"name":"entity.name.function.jsonnet"}},"end":"\\\\)","name":"meta.function","patterns":[{"include":"#expression"}]}]},"keywords":{"patterns":[{"match":"[-!%\\\\&*+/:<=>^|~]","name":"keyword.operator.jsonnet"},{"match":"\\\\$","name":"keyword.other.jsonnet"},{"match":"\\\\b(self|super|import|importstr|local|tailstrict)\\\\b","name":"keyword.other.jsonnet"},{"match":"\\\\b(if|then|else|for|in|error|assert)\\\\b","name":"keyword.control.jsonnet"},{"match":"\\\\b(function)\\\\b","name":"storage.type.jsonnet"},{"match":"[A-Z_a-z][0-9A-Z_a-z]*\\\\s*(\\\\+??:::)","name":"variable.parameter.jsonnet"},{"match":"[A-Z_a-z][0-9A-Z_a-z]*\\\\s*(\\\\+??::)","name":"entity.name.type"},{"match":"[A-Z_a-z][0-9A-Z_a-z]*\\\\s*(\\\\+??:)","name":"variable.parameter.jsonnet"}]},"literals":{"patterns":[{"match":"\\\\b(true|false|null)\\\\b","name":"constant.language.jsonnet"},{"match":"\\\\b(\\\\d+([Ee][-+]?\\\\d+)?)\\\\b","name":"constant.numeric.jsonnet"},{"match":"\\\\b\\\\d+\\\\.\\\\d*([Ee][-+]?\\\\d+)?\\\\b","name":"constant.numeric.jsonnet"},{"match":"\\\\b\\\\.\\\\d+([Ee][-+]?\\\\d+)?\\\\b","name":"constant.numeric.jsonnet"}]},"single-quoted-strings":{"begin":"\'","end":"\'","name":"string.quoted.double.jsonnet","patterns":[{"match":"\\\\\\\\([\'/\\\\\\\\bfnrt]|(u\\\\h{4}))","name":"constant.character.escape.jsonnet"},{"match":"\\\\\\\\[^\'/\\\\\\\\bfnrtu]","name":"invalid.illegal.jsonnet"}]},"triple-quoted-strings":{"patterns":[{"begin":"\\\\|\\\\|\\\\|","end":"\\\\|\\\\|\\\\|","name":"string.quoted.triple.jsonnet"}]}},"scopeName":"source.jsonnet"}')),t=[n];export{t as default};
|
||||
|
||||
//# debugId=2E5CA6894F6BED9364756E2164756E21
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1 @@
|
|||
import{e as a}from"./chunk-4j8a65qz.js";import"./chunk-8v6fnbbx.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=A93D7704B874887E64756E2164756E21
|
||||
|
|
|
|||
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
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
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
|
|
@ -1,3 +1 @@
|
|||
import{B as a}from"./chunk-02n7dnxd.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=02F875873A3E61E564756E2164756E21
|
||||
|
|
|
|||
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
|
|
@ -1,4 +1,2 @@
|
|||
var e=Object.freeze(JSON.parse('{"displayName":"1C (Query)","fileTypes":["sdbl","query"],"firstLineMatch":"(?i)Выбрать|Select(\\\\s+Разрешенные|\\\\s+Allowed)?(\\\\s+Различные|\\\\s+Distinct)?(\\\\s+Первые|\\\\s+Top)?.*","name":"sdbl","patterns":[{"match":"^(\\\\s*//.*)$","name":"comment.line.double-slash.sdbl"},{"begin":"//","end":"$","name":"comment.line.double-slash.sdbl"},{"begin":"\\"","end":"\\"(?!\\")","name":"string.quoted.double.sdbl","patterns":[{"match":"\\"\\"","name":"constant.character.escape.sdbl"},{"match":"^(\\\\s*//.*)$","name":"comment.line.double-slash.sdbl"}]},{"match":"(?i)(?<=[^.а-яё\\\\w]|^)(Неопределено|Undefined|Истина|True|Ложь|False|NULL)(?=[^.а-яё\\\\w]|$)","name":"constant.language.sdbl"},{"match":"(?<=[^.а-яё\\\\w]|^)(\\\\d+\\\\.?\\\\d*)(?=[^.а-яё\\\\w]|$)","name":"constant.numeric.sdbl"},{"match":"(?i)(?<=[^.а-яё\\\\w]|^)(Выбор|Case|Когда|When|Тогда|Then|Иначе|Else|Конец|End)(?=[^.а-яё\\\\w]|$)","name":"keyword.control.conditional.sdbl"},{"match":"(?i)(?<!КАК\\\\s|AS\\\\s)(?<=[^.а-яё\\\\w]|^)(НЕ|NOT|И|AND|ИЛИ|OR|В\\\\s+ИЕРАРХИИ|IN\\\\s+HIERARCHY|В|In|Между|Between|Есть(\\\\s+НЕ)?\\\\s+NULL|Is(\\\\s+NOT)?\\\\s+NULL|Ссылка|Refs|Подобно|Like)(?=[^.а-яё\\\\w]|$)","name":"keyword.operator.logical.sdbl"},{"match":"<=|>=|[<=>]","name":"keyword.operator.comparison.sdbl"},{"match":"([-%*+/])","name":"keyword.operator.arithmetic.sdbl"},{"match":"([,;])","name":"keyword.operator.sdbl"},{"match":"(?i)(?<=[^.а-яё\\\\w]|^)(Выбрать|Select|Разрешенные|Allowed|Различные|Distinct|Первые|Top|Как|As|ПустаяТаблица|EmptyTable|Поместить|Into|Уничтожить|Drop|Из|From|((Левое|Left|Правое|Right|Полное|Full)\\\\s+(Внешнее\\\\s+|Outer\\\\s+)?Соединение|Join)|((Внутреннее|Inner)\\\\s+Соединение|Join)|Где|Where|(Сгруппировать\\\\s+По(\\\\s+Группирующим\\\\s+Наборам)?)|(Group\\\\s+By(\\\\s+Grouping\\\\s+Set)?)|Имеющие|Having|Объединить(\\\\s+Все)?|Union(\\\\s+All)?|(Упорядочить\\\\s+По)|(Order\\\\s+By)|Автоупорядочивание|Autoorder|Итоги|Totals|По(\\\\s+Общие)?|By(\\\\s+Overall)?|(Только\\\\s+)?Иерархия|(Only\\\\s+)?Hierarchy|Периодами|Periods|Индексировать|Index|Выразить|Cast|Возр|Asc|Убыв|Desc|Для\\\\s+Изменения|(For\\\\s+Update(\\\\s+Of)?)|Спецсимвол|Escape|СгруппированоПо|GroupedBy)(?=[^.а-яё\\\\w]|$)","name":"keyword.control.sdbl"},{"match":"(?i)(?<=[^.а-яё\\\\w]|^)(Значение|Value|ДатаВремя|DateTime|Тип|Type)(?=\\\\()","name":"support.function.sdbl"},{"match":"(?i)(?<=[^.а-яё\\\\w]|^)(Подстрока|Substring|НРег|Lower|ВРег|Upper|Лев|Left|Прав|Right|ДлинаСтроки|StringLength|СтрНайти|StrFind|СтрЗаменить|StrReplace|СокрЛП|TrimAll|СокрЛ|TrimL|СокрП|TrimR)(?=\\\\()","name":"support.function.sdbl"},{"match":"(?i)(?<=[^.а-яё\\\\w]|^)(Год|Year|Квартал|Quarter|Месяц|Month|ДеньГода|DayOfYear|День|Day|Неделя|Week|ДеньНедели|Weekday|Час|Hour|Минута|Minute|Секунда|Second|НачалоПериода|BeginOfPeriod|КонецПериода|EndOfPeriod|ДобавитьКДате|DateAdd|РазностьДат|DateDiff|Полугодие|HalfYear|Декада|TenDays)(?=\\\\()","name":"support.function.sdbl"},{"match":"(?i)(?<=[^.а-яё\\\\w]|^)(ACOS|COS|ASIN|SIN|ATAN|TAN|EXP|POW|LOG|LOG10|Цел|Int|Окр|Round|SQRT)(?=\\\\()","name":"support.function.sdbl"},{"match":"(?i)(?<=[^.а-яё\\\\w]|^)(Сумма|Sum|Среднее|Avg|Минимум|Min|Максимум|Max|Количество|Count)(?=\\\\()","name":"support.function.sdbl"},{"match":"(?i)(?<=[^.а-яё\\\\w]|^)(ЕстьNULL|IsNULL|Представление|Presentation|ПредставлениеСсылки|RefPresentation|ТипЗначения|ValueType|АвтономерЗаписи|RecordAutoNumber|РазмерХранимыхДанных|StoredDataSize|УникальныйИдентификатор|UUID)(?=\\\\()","name":"support.function.sdbl"},{"match":"(?i)(?<=[^.а-яё\\\\w])(Число|Number|Строка|String|Дата|Date|Булево|Boolean)(?=[^.а-яё\\\\w]|$)","name":"support.type.sdbl"},{"match":"(&[а-яё\\\\w]+)","name":"variable.parameter.sdbl"}],"scopeName":"source.sdbl","aliases":["1c-query"]}')),s=[e];
|
||||
export{s as H};
|
||||
|
||||
//# debugId=6CD2992EEB7683AB64756E2164756E21
|
||||
|
|
|
|||
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1 @@
|
|||
import"./chunk-q07bg6gn.js";var e=Object.freeze(JSON.parse('{"displayName":"Wenyan","name":"wenyan","patterns":[{"include":"#keywords"},{"include":"#constants"},{"include":"#operators"},{"include":"#symbols"},{"include":"#expression"},{"include":"#comment-blocks"},{"include":"#comment-lines"}],"repository":{"comment-blocks":{"begin":"([批注疏]曰)。?(「「|『)","end":"(」」|』)","name":"comment.block","patterns":[{"match":"\\\\\\\\.","name":"constant.character"}]},"comment-lines":{"begin":"[批注疏]曰","end":"$","name":"comment.line","patterns":[{"match":"\\\\\\\\.","name":"constant.character"}]},"constants":{"patterns":[{"match":"[·〇一七三九二五京億兆八六分十千又四垓埃塵微忽極正毫沙渺溝漠澗百秭穰絲纖萬負載釐零]","name":"constant.numeric"},{"match":"[其陰陽]","name":"constant.language"},{"begin":"「「|『","end":"」」|』","name":"string.quoted","patterns":[{"match":"\\\\\\\\.","name":"constant.character"}]}]},"expression":{"patterns":[{"include":"#variables"}]},"keywords":{"patterns":[{"match":"[元列數爻物術言]","name":"storage.type"},{"match":"乃行是術曰|若其不然者|乃歸空無|欲行是術|乃止是遍|若其然者|其物如是|乃得矣|之術也|必先得|是術曰|恆為是|之物也|乃得|是謂|云云|中之|為是|乃止|若非|或若|之長|其餘","name":"keyword.control"},{"match":"或云|蓋謂","name":"keyword.control"},{"match":"中有陽乎|中無陰乎|所餘幾何|不等於|不大於|不小於|等於|大於|小於|[乘以加於減變除]","name":"keyword.operator"},{"match":"不知何禍歟|不復存矣|姑妄行此|如事不諧|名之曰|吾嘗觀|之禍歟|乃作罷|吾有|今有|物之|書之|以施|昔之|是矣|之書|方悟|之義|嗚呼|之禍|[中今取噫夫施曰有豈]","name":"keyword.other"},{"match":"[之也充凡者若遍銜]","name":"keyword.control"}]},"symbols":{"patterns":[{"match":"[、。]","name":"punctuation.separator"}]},"variables":{"begin":"「","end":"」","name":"variable.other","patterns":[{"match":"\\\\\\\\.","name":"constant.character"}]}},"scopeName":"source.wenyan","aliases":["文言"]}')),n=[e];export{n as default};
|
||||
|
||||
//# debugId=C31B6B4CB9F7148464756E2164756E21
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
import{b as a}from"./chunk-kv06br00.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=6BCBC209959768EC64756E2164756E21
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1 @@
|
|||
import{N as a}from"./chunk-v61ks9f7.js";import"./chunk-q07bg6gn.js";export{a as default};
|
||||
|
||||
//# debugId=03F6F81A09CCAA2A64756E2164756E21
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
import"./chunk-q07bg6gn.js";var a=Object.freeze(JSON.parse('{"displayName":"Log file","fileTypes":["log"],"name":"log","patterns":[{"match":"\\\\b([Tt]race|TRACE)\\\\b:?","name":"comment log.verbose"},{"match":"(?i)\\\\[(v(?:erbose|erb|rb|b?))]","name":"comment log.verbose"},{"match":"(?<=^[p\\\\s\\\\d]*)\\\\bV\\\\b","name":"comment log.verbose"},{"match":"\\\\b(D(?:EBUG|ebug))\\\\b|(?i)\\\\b(debug):","name":"markup.changed log.debug"},{"match":"(?i)\\\\[(d(?:ebug|bug|bg|e?))]","name":"markup.changed log.debug"},{"match":"(?<=^[p\\\\s\\\\d]*)\\\\bD\\\\b","name":"markup.changed log.debug"},{"match":"\\\\b(HINT|INFO|INFORMATION|Info|NOTICE|II)\\\\b|(?i)\\\\b(info(?:|rmation)):","name":"markup.inserted log.info"},{"match":"(?i)\\\\[(i(?:nformation|nfo?|n?))]","name":"markup.inserted log.info"},{"match":"(?<=^[p\\\\s\\\\d]*)\\\\bI\\\\b","name":"markup.inserted log.info"},{"match":"\\\\b(W(?:ARNING|ARN|arn|W))\\\\b|(?i)\\\\b(warning):","name":"markup.deleted log.warning"},{"match":"(?i)\\\\[(w(?:arning|arn|rn|n?))]","name":"markup.deleted log.warning"},{"match":"(?<=^[p\\\\s\\\\d]*)\\\\bW\\\\b","name":"markup.deleted log.warning"},{"match":"\\\\b(ALERT|CRITICAL|EMERGENCY|ERROR|FAILURE|FAIL|Fatal|FATAL|Error|EE)\\\\b|(?i)\\\\b(error):","name":"string.regexp, strong log.error"},{"match":"(?i)\\\\[(error|eror|err?|e|fatal|fatl|ftl|fa?)]","name":"string.regexp, strong log.error"},{"match":"(?<=^[p\\\\s\\\\d]*)\\\\bE\\\\b","name":"string.regexp, strong log.error"},{"match":"\\\\b\\\\d{4}-\\\\d{2}-\\\\d{2}(?=T|\\\\b)","name":"comment log.date"},{"match":"(?<=(^|\\\\s))\\\\d{2}[^\\\\w\\\\s]\\\\d{2}[^\\\\w\\\\s]\\\\d{4}\\\\b","name":"comment log.date"},{"match":"T?\\\\d{1,2}:\\\\d{2}(:\\\\d{2}([,.]\\\\d+)?)?(Z| ?[-+]\\\\d{1,2}:\\\\d{2})?\\\\b","name":"comment log.date"},{"match":"T\\\\d{2}\\\\d{2}(\\\\d{2}([,.]\\\\d+)?)?(Z| ?[-+]\\\\d{1,2}\\\\d{2})?\\\\b","name":"comment log.date"},{"match":"\\\\b(\\\\h{40}|\\\\h{10}|\\\\h{7})\\\\b","name":"constant.language"},{"match":"\\\\b\\\\h{8}-?(\\\\h{4}-?){3}\\\\h{12}\\\\b","name":"constant.language log.constant"},{"match":"\\\\b(\\\\h{2,}[-:])+\\\\h{2,}+\\\\b","name":"constant.language log.constant"},{"match":"\\\\b([0-9]+|true|false|null)\\\\b","name":"constant.language log.constant"},{"match":"\\\\b(0x\\\\h+)\\\\b","name":"constant.language log.constant"},{"match":"\\"[^\\"]*\\"","name":"string log.string"},{"match":"(?<!\\\\w)\'[^\']*\'","name":"string log.string"},{"match":"\\\\b([.A-Za-z]*Exception)\\\\b","name":"string.regexp, emphasis log.exceptiontype"},{"begin":"^[\\\\t ]*at[\\\\t ]","end":"$","name":"string.key, emphasis log.exception"},{"match":"\\\\b[a-z]+://\\\\S+\\\\b/?","name":"constant.language log.constant"},{"match":"(?<![/\\\\\\\\\\\\w])([-\\\\w]+\\\\.)+([-\\\\w])+(?![/\\\\\\\\\\\\w])","name":"constant.language log.constant"}],"scopeName":"text.log"}')),e=[a];export{e as default};
|
||||
|
||||
//# debugId=F7741D1B6F414CDA64756E2164756E21
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue