375 lines · 12.2 KB · TypeScript
RawDownload
import { Data, Effect, Schema } from "effect";import {  EXECUTOR_ORG_HEADER,  getActiveOrgSlug,  getExecutorApiBaseUrl,  getExecutorServerAuthorizationHeader,} from "@executor-js/react/api/server-connection"; export const CUSTOM_TOOLS_PLUGIN_KEY = "apps";export const CUSTOM_TOOLS_LABEL = "Custom tools"; export type AppSourceKind = "git" | "local-directory"; export interface SyncDiagnostic {  readonly stage: "source" | "discover" | "bundle" | "collect" | "project";  readonly message: string;  readonly diagnostics?: readonly { readonly path: string; readonly message: string }[];} export type SourceStatus =  | { readonly type: "pending" }  | {      readonly type: "published" | "up-to-date";      readonly at: number;      readonly tools: readonly string[];    }  | { readonly type: "failed"; readonly at: number; readonly errors: readonly SyncDiagnostic[] }; export type AppSourceRecord =  | {      readonly slug: string;      readonly app: string;      readonly kind: "git";      readonly config: {        readonly kind: "git";        readonly url: string;        readonly ref?: string;        readonly tokenProvider?: string;        readonly tokenItemId?: string;      };      readonly sourceRef?: string;      readonly description?: string;      readonly status: SourceStatus;      readonly updatedAt: number;    }  | {      readonly slug: string;      readonly app: string;      readonly kind: "local-directory";      readonly config: { readonly kind: "local-directory"; readonly path: string };      readonly sourceRef?: string;      readonly description?: string;      readonly status: SourceStatus;      readonly updatedAt: number;    }; export interface SourcesListResponse {  readonly sources: readonly AppSourceRecord[];} export interface SourceDetailResponse {  readonly source: AppSourceRecord | null;} export type CreateSourceRequest =  | {      readonly kind: "git";      readonly slug?: string;      readonly app?: string;      readonly url: string;      readonly ref?: string;      readonly token?: string;    }  | {      readonly kind: "local-directory";      readonly slug?: string;      readonly app?: string;      readonly path: string;    }; export interface CreateSourceResponse {  readonly source: AppSourceRecord;} export type SyncSourceResult =  | {      readonly status: "published" | "up-to-date";      readonly sourceRef: string;      readonly tools: readonly string[];      readonly errors?: undefined;    }  | {      readonly status: "failed";      readonly sourceRef?: string;      readonly tools: readonly string[];      readonly errors: readonly SyncDiagnostic[];    }; export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; const customToolsFetch: CustomToolsFetch = (input, init = {}) => {  const headers = new Headers(init.headers);  const authorization = getExecutorServerAuthorizationHeader();  if (authorization && !headers.has("authorization")) {    headers.set("authorization", authorization);  }  const orgSlug = getActiveOrgSlug();  if (orgSlug && !headers.has(EXECUTOR_ORG_HEADER)) {    headers.set(EXECUTOR_ORG_HEADER, orgSlug);  }  const url =    typeof input === "string" && input.startsWith("/")      ? new URL(input, getExecutorApiBaseUrl()).toString()      : input;  return fetch(url, { ...init, headers });}; export interface CustomToolsDirectoryEntry {  readonly name: string;  readonly path: string;  readonly isSymlink: boolean;  readonly hasTools: boolean;} export interface CustomToolsDirectorySourceShape {  readonly toolFiles: readonly string[];  readonly skipped: readonly string[];  readonly hasPackageJson: boolean;} export interface CustomToolsDirectoryListing {  readonly path: string;  readonly parent: string | null;  readonly dirs: readonly CustomToolsDirectoryEntry[];  readonly source: CustomToolsDirectorySourceShape;} export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{  readonly message: string;}> {} export const slugifyCustomToolsAppName = (value: string): string =>  value    .trim()    .toLowerCase()    .replace(/[^a-z0-9-]+/g, "-")    .replace(/^-+|-+$/g, "")    .slice(0, 80); export const validateCustomToolsAppSlug = (value: string): string | null => {  if (!value.trim()) return "Enter a source name.";  if (value !== slugifyCustomToolsAppName(value)) {    return "Use lowercase letters, numbers, and hyphens.";  }  return null;}; export const parseGitSourceUrl = (  raw: string,):  | { readonly ok: true; readonly url: string; readonly name: string }  | { readonly ok: false; readonly message: string } => {  const value = raw.trim();  if (!value) return { ok: false, message: "Enter a Git repository URL." };  if (!URL.canParse(value)) return { ok: false, message: "Enter an https Git repository URL." };  const parsed = new URL(value);  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {    return { ok: false, message: "Git repository URLs must use http or https." };  }  if (parsed.username || parsed.password) {    return { ok: false, message: "Do not include credentials in the URL. Use the token field." };  }  const segments = parsed.pathname.split("/").filter(Boolean);  const last = segments.at(-1)?.replace(/\.git$/, "") ?? "";  if (!last) return { ok: false, message: "Enter a repository URL with a path." };  parsed.hash = "";  return { ok: true, url: parsed.toString(), name: last };}; export const suggestCustomToolsAppName = (urlOrPath: string): string => {  const parsed = parseGitSourceUrl(urlOrPath);  if (parsed.ok) return parsed.name;  const pathParts = urlOrPath.split("/").filter(Boolean);  return pathParts.at(-1) ?? "";}; export const validateGitSourceUrl = (url: string): string | null => {  const parsed = parseGitSourceUrl(url);  return parsed.ok ? null : parsed.message;}; const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); const isRecord = (value: unknown): value is Record<string, unknown> =>  value !== null && typeof value === "object" && !Array.isArray(value); const responseErrorMessage = (body: unknown, fallback: string): string =>  isRecord(body) && typeof body.message === "string"    ? body.message    : isRecord(body) && typeof body.error === "string"      ? body.error      : fallback; const parseJsonResponseEffect = <A>(  response: Response,  fallback: string,): Effect.Effect<A, CustomToolsClientError> =>  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 = customToolsFetch,): Effect.Effect<SourcesListResponse, CustomToolsClientError> =>  Effect.tryPromise({    try: () => fetchImpl("/api/apps/sources", { credentials: "same-origin" }),    catch: () => new CustomToolsClientError({ message: "Failed to load custom tools sources." }),  }).pipe(    Effect.flatMap((response) =>      parseJsonResponseEffect<SourcesListResponse>(        response,        "Failed to load custom tools sources.",      ),    ),  ); export const listCustomToolSources = (  fetchImpl: CustomToolsFetch = customToolsFetch,): Promise<SourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl)); export const getCustomToolSourceEffect = (  slug: string,  fetchImpl: CustomToolsFetch = customToolsFetch,): Effect.Effect<SourceDetailResponse, CustomToolsClientError> =>  Effect.tryPromise({    try: () =>      fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, {        credentials: "same-origin",      }),    catch: () => new CustomToolsClientError({ message: "Failed to load custom tools source." }),  }).pipe(    Effect.flatMap((response) =>      parseJsonResponseEffect<SourceDetailResponse>(        response,        "Failed to load custom tools source.",      ),    ),  ); export const createCustomToolSourceEffect = (  input: CreateSourceRequest,  fetchImpl: CustomToolsFetch = customToolsFetch,): Effect.Effect<CreateSourceResponse, CustomToolsClientError> =>  Effect.tryPromise({    try: () =>      fetchImpl("/api/apps/sources", {        method: "POST",        headers: { "content-type": "application/json" },        credentials: "same-origin",        body: encodeJsonText(input),      }),    catch: () => new CustomToolsClientError({ message: "Failed to create custom tools source." }),  }).pipe(    Effect.flatMap((response) =>      parseJsonResponseEffect<CreateSourceResponse>(        response,        "Failed to create custom tools source.",      ),    ),  ); export const listCustomToolDirectoriesEffect = (  input: { readonly path?: string; readonly includeHidden?: boolean } = {},  fetchImpl: CustomToolsFetch = customToolsFetch,): Effect.Effect<CustomToolsDirectoryListing, CustomToolsClientError> =>  Effect.tryPromise({    try: () => {      const query = new URLSearchParams();      if (input.path) query.set("path", input.path);      if (input.includeHidden) query.set("includeHidden", "true");      const suffix = query.size > 0 ? `?${query.toString()}` : "";      return fetchImpl(`/api/apps/fs/dirs${suffix}`, { credentials: "same-origin" });    },    catch: () => new CustomToolsClientError({ message: "Failed to browse directories." }),  }).pipe(    Effect.flatMap((response) =>      parseJsonResponseEffect<CustomToolsDirectoryListing>(        response,        "Failed to browse directories.",      ),    ),  ); export const syncCustomToolSourceEffect = (  slug: string,  fetchImpl: CustomToolsFetch = customToolsFetch,): Effect.Effect<SyncSourceResult, CustomToolsClientError> =>  Effect.tryPromise({    try: () =>      fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}/sync`, {        method: "POST",        credentials: "same-origin",      }),    catch: () => new CustomToolsClientError({ message: "Failed to sync custom tools source." }),  }).pipe(    Effect.flatMap((response) =>      parseJsonResponseEffect<SyncSourceResult>(response, "Failed to sync custom tools source."),    ),  ); export const removeCustomToolSourceEffect = (  slug: string,  fetchImpl: CustomToolsFetch = customToolsFetch,): Effect.Effect<{ readonly removed: boolean }, CustomToolsClientError> =>  Effect.tryPromise({    try: () =>      fetchImpl(`/api/apps/sources/${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 syncStatusLabel = (result: SyncSourceResult): 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 formatDiagnostics = (  errors: readonly SyncDiagnostic[] | undefined,): readonly string[] =>  (errors ?? []).flatMap((entry) => {    const head = `${entry.stage}: ${entry.message}`;    const details = entry.diagnostics?.map((d) => `${d.path}: ${d.message}`) ?? [];    return details.length > 0 ? [head, ...details] : [head];  }); export const formatSyncErrors = (result: SyncSourceResult): readonly string[] =>  result.status === "failed" ? formatDiagnostics(result.errors) : []; 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)),  };};