import { Data, Effect, Schema } from "effect"; import type { GitHubCustomToolsSourceSummary, GitHubSyncResult } from "../api"; import { parseGitHubSourceUrl } from "../source/github-url"; import { slugifyCustomToolsAppName, validateCustomToolsAppSlug } from "../source/app-slug"; export const CUSTOM_TOOLS_PLUGIN_KEY = "apps"; export const CUSTOM_TOOLS_LABEL = "Custom tools"; export interface GitHubSourcesListResponse { readonly sources: readonly GitHubCustomToolsSourceSummary[]; } export interface GitHubSourceDetailResponse { readonly source: GitHubCustomToolsSourceSummary | null; } export interface SyncGitHubSourceRequest { readonly url?: string; readonly name?: string; readonly slug?: string; readonly ref?: string; readonly token?: string; } export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{ readonly message: string; }> {} export { parseGitHubSourceUrl }; export { slugifyCustomToolsAppName, validateCustomToolsAppSlug }; export const suggestCustomToolsAppName = (url: string): string => { const parsed = parseGitHubSourceUrl(url); return parsed.ok ? parsed.value.name : ""; }; export const validateGitHubSourceUrl = (url: string): string | null => { const parsed = parseGitHubSourceUrl(url); return parsed.ok ? null : parsed.message; }; export const validateGitHubRepo = validateGitHubSourceUrl; const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); const isRecord = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); const responseErrorMessage = (body: unknown, fallback: string): string => isRecord(body) && typeof body.error === "string" ? body.error : fallback; const parseJsonResponseEffect = ( response: Response, fallback: string, ): Effect.Effect => Effect.gen(function* () { const text = yield* Effect.tryPromise({ try: () => response.text(), catch: () => new CustomToolsClientError({ message: fallback }), }); const body = text.length > 0 ? yield* decodeJsonText(text).pipe( Effect.mapError(() => new CustomToolsClientError({ message: fallback })), ) : null; if (!response.ok) { return yield* new CustomToolsClientError({ message: responseErrorMessage(body, fallback), }); } return body as A; }); export const listCustomToolSourcesEffect = ( fetchImpl: CustomToolsFetch = fetch, ): Effect.Effect => Effect.tryPromise({ try: () => fetchImpl("/api/apps/sources/github", { credentials: "same-origin", }), catch: () => new CustomToolsClientError({ message: "Failed to load custom tools." }), }).pipe( Effect.flatMap((response) => parseJsonResponseEffect(response, "Failed to load custom tools."), ), ); export const listCustomToolSources = ( fetchImpl: CustomToolsFetch = fetch, ): Promise => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl)); export const getCustomToolSourceEffect = ( slug: string, fetchImpl: CustomToolsFetch = fetch, ): Effect.Effect => Effect.tryPromise({ try: () => fetchImpl(`/api/apps/sources/github/${encodeURIComponent(slug)}`, { credentials: "same-origin", }), catch: () => new CustomToolsClientError({ message: "Failed to load custom tools source." }), }).pipe( Effect.flatMap((response) => parseJsonResponseEffect( response, "Failed to load custom tools source.", ), ), ); export const getCustomToolSource = ( slug: string, fetchImpl: CustomToolsFetch = fetch, ): Promise => Effect.runPromise(getCustomToolSourceEffect(slug, fetchImpl)); export const syncCustomToolSourceEffect = ( input: SyncGitHubSourceRequest, fetchImpl: CustomToolsFetch = fetch, ): Effect.Effect => Effect.tryPromise({ try: () => fetchImpl("/api/apps/sources/github/sync", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ ...(input.url?.trim() ? { url: input.url.trim() } : {}), ...(input.name?.trim() ? { name: input.name.trim() } : {}), ...(input.slug?.trim() ? { slug: input.slug.trim() } : {}), ...(input.ref?.trim() ? { ref: input.ref.trim() } : {}), ...(input.token?.trim() ? { token: input.token.trim() } : {}), }), }), catch: () => new CustomToolsClientError({ message: "Failed to sync custom tools." }), }).pipe( Effect.flatMap((response) => parseJsonResponseEffect(response, "Failed to sync custom tools."), ), ); export const syncCustomToolSource = ( input: SyncGitHubSourceRequest, fetchImpl: CustomToolsFetch = fetch, ): Promise => Effect.runPromise(syncCustomToolSourceEffect(input, fetchImpl)); export const removeCustomToolSourceEffect = ( slug: string, fetchImpl: CustomToolsFetch = fetch, ): Effect.Effect<{ readonly removed: boolean }, CustomToolsClientError> => Effect.tryPromise({ try: () => fetchImpl(`/api/apps/sources/github/${encodeURIComponent(slug)}`, { method: "DELETE", credentials: "same-origin", }), catch: () => new CustomToolsClientError({ message: "Failed to remove custom tools source." }), }).pipe( Effect.flatMap((response) => parseJsonResponseEffect<{ readonly removed: boolean }>( response, "Failed to remove custom tools source.", ), ), ); export const removeCustomToolSource = ( slug: string, fetchImpl: CustomToolsFetch = fetch, ): Promise<{ readonly removed: boolean }> => Effect.runPromise(removeCustomToolSourceEffect(slug, fetchImpl)); export const syncStatusLabel = (result: GitHubSyncResult): string => { if (result.status === "published") return `Published ${result.tools.length} tools.`; if (result.status === "up-to-date") return "Already up to date."; return "Sync failed."; }; export const formatSyncErrors = (result: GitHubSyncResult): readonly string[] => { if (result.status !== "failed") return []; return result.errors.map((entry) => { const message = entry.message; const stage = entry.stage; const details = entry.diagnostics?.map((d) => `${d.path}: ${d.message}`).join("; "); return details ? `${stage}: ${message} (${details})` : `${stage}: ${message}`; }); }; export const toolDiff = ( before: readonly string[], after: readonly string[], ): { readonly added: readonly string[]; readonly removed: readonly string[] } => { const beforeSet = new Set(before); const afterSet = new Set(after); return { added: after.filter((tool) => !beforeSet.has(tool)), removed: before.filter((tool) => !afterSet.has(tool)), }; }; export const consoleIntegrationHref = (path: string): string => { if (typeof window === "undefined") return path; const marker = "/integrations"; const index = window.location.pathname.indexOf(marker); const prefix = index === -1 ? "" : window.location.pathname.slice(0, index); return `${prefix}${path}`; };