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:
Bryan Helmkamp 2026-04-19 11:20:58 -04:00
parent 49ef284ed5
commit ecdfdd82d8
No known key found for this signature in database
390 changed files with 7088 additions and 2876 deletions

15
Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -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) {

View 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");
});
});

View 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>;
}

File diff suppressed because it is too large Load diff

View 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,
},
],
},
];

View 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",
});
});
});

View 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(),
};
}

View file

@ -23,7 +23,6 @@ async function buildOnce() {
minify: true,
splitting: true,
target: "browser",
sourcemap: "external",
});
if (!result.success) {

View file

@ -2,6 +2,7 @@ services:
fabro:
image: ghcr.io/fabro-sh/fabro:nightly
platform: linux/amd64
restart: unless-stopped
ports:
- "32276:32276"
volumes:

View file

@ -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

View file

@ -1,2 +0,0 @@
[server.storage]
root = "/storage"

View file

@ -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:

View file

@ -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();

View file

@ -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."
);
}
}

View file

@ -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}"
);
}
}

View file

@ -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));
}

View file

@ -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!();

View file

@ -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
);
}

View 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"

View 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")
);
}
}

View file

@ -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" }

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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 {

View file

@ -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,

View file

@ -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)
}

View 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")
);
}

View file

@ -1,3 +1,4 @@
mod install;
mod routing;
mod runs;
mod settings;

View file

@ -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);

View file

@ -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

View file

@ -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| {

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

View file

@ -1,3 +1 @@
import{E as a}from"./chunk-k28qfdjw.js";import"./chunk-q07bg6gn.js";export{a as default};
//# debugId=B2B8C7571ED9C9F664756E2164756E21

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,3 +1 @@
import{N as a}from"./chunk-v61ks9f7.js";import"./chunk-q07bg6gn.js";export{a as default};
//# debugId=03F6F81A09CCAA2A64756E2164756E21

View file

@ -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