diff --git a/.gitignore b/.gitignore index 9e9ca20060..055245255d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,9 +46,16 @@ apps/desktop/resources/ # cloud local dev database .pglite +apps/cloud/.dev-db/ .claude/ .nitro/ .output/ .tanstack/ .env* -!.env.example \ No newline at end of file +!.env.example + +# local user config +apps/local/executor.jsonc + +# VS Code (per-workspace user settings) +.vscode/mcp.json \ No newline at end of file diff --git a/apps/cloud/src/routes/sources.$namespace.tsx b/apps/cloud/src/routes/sources.$namespace.tsx index 0e8aa562aa..940387cfe3 100644 --- a/apps/cloud/src/routes/sources.$namespace.tsx +++ b/apps/cloud/src/routes/sources.$namespace.tsx @@ -1,9 +1,20 @@ import { createFileRoute } from "@tanstack/react-router"; import { SourceDetailPage } from "@executor/react/pages/source-detail"; +import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; +import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; +import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react"; +import { graphqlSourcePlugin } from "@executor/plugin-graphql/react"; + +const sourcePlugins = [ + openApiSourcePlugin, + mcpSourcePlugin, + googleDiscoverySourcePlugin, + graphqlSourcePlugin, +]; export const Route = createFileRoute("/sources/$namespace")({ component: () => { const { namespace } = Route.useParams(); - return ; + return ; }, }); diff --git a/apps/local/src/routes/sources.$namespace.tsx b/apps/local/src/routes/sources.$namespace.tsx index 0e8aa562aa..940387cfe3 100644 --- a/apps/local/src/routes/sources.$namespace.tsx +++ b/apps/local/src/routes/sources.$namespace.tsx @@ -1,9 +1,20 @@ import { createFileRoute } from "@tanstack/react-router"; import { SourceDetailPage } from "@executor/react/pages/source-detail"; +import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; +import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; +import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react"; +import { graphqlSourcePlugin } from "@executor/plugin-graphql/react"; + +const sourcePlugins = [ + openApiSourcePlugin, + mcpSourcePlugin, + googleDiscoverySourcePlugin, + graphqlSourcePlugin, +]; export const Route = createFileRoute("/sources/$namespace")({ component: () => { const { namespace } = Route.useParams(); - return ; + return ; }, }); diff --git a/apps/local/src/server/executor.ts b/apps/local/src/server/executor.ts index 185e7d6e09..140f8a0c11 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/server/executor.ts @@ -13,14 +13,15 @@ import { makeScopedKv, migrate, } from "@executor/storage-file"; -import { withConfigFile } from "@executor/config"; import { openApiPlugin, makeKvOperationStore, + withConfigFile as withOpenApiConfigFile, } from "@executor/plugin-openapi"; import { mcpPlugin, makeKvBindingStore, + withConfigFile as withMcpConfigFile, } from "@executor/plugin-mcp"; import { googleDiscoveryPlugin, @@ -29,6 +30,7 @@ import { import { graphqlPlugin, makeKvOperationStore as makeKvGraphqlOperationStore, + withConfigFile as withGraphqlConfigFile, } from "@executor/plugin-graphql"; import { keychainPlugin } from "@executor/plugin-keychain"; import { fileSecretsPlugin } from "@executor/plugin-file-secrets"; @@ -55,14 +57,14 @@ const createLocalPlugins = ( ) => [ openApiPlugin({ - operationStore: withConfigFile.openapi( + operationStore: withOpenApiConfigFile( makeKvOperationStore(scopedKv, "openapi"), configPath, fsLayer, ), }), mcpPlugin({ - bindingStore: withConfigFile.mcp( + bindingStore: withMcpConfigFile( makeKvBindingStore(scopedKv, "mcp"), configPath, fsLayer, @@ -75,7 +77,7 @@ const createLocalPlugins = ( ), }), graphqlPlugin({ - operationStore: withConfigFile.graphql( + operationStore: withGraphqlConfigFile( makeKvGraphqlOperationStore(scopedKv, "graphql"), configPath, fsLayer, diff --git a/bun.lock b/bun.lock index e1046452d2..dcf41a493b 100644 --- a/bun.lock +++ b/bun.lock @@ -562,6 +562,7 @@ "@apidevtools/swagger-parser": "^12.1.0", "@effect/platform": "catalog:", "@effect/platform-node": "catalog:", + "@executor/config": "workspace:*", "@executor/sdk": "workspace:*", "effect": "catalog:", "openapi-types": "^12.1.3", diff --git a/packages/core/api/src/handlers/sources.ts b/packages/core/api/src/handlers/sources.ts index 86e2cc2aec..902f112405 100644 --- a/packages/core/api/src/handlers/sources.ts +++ b/packages/core/api/src/handlers/sources.ts @@ -20,6 +20,7 @@ export const SourcesHandlers = HttpApiBuilder.group( runtime: s.runtime, canRemove: s.canRemove, canRefresh: s.canRefresh, + canEdit: s.canEdit, })); }), ) diff --git a/packages/core/api/src/sources/api.ts b/packages/core/api/src/sources/api.ts index 19fd75fee6..891690a729 100644 --- a/packages/core/api/src/sources/api.ts +++ b/packages/core/api/src/sources/api.ts @@ -20,6 +20,7 @@ const SourceResponse = Schema.Struct({ runtime: Schema.optional(Schema.Boolean), canRemove: Schema.optional(Schema.Boolean), canRefresh: Schema.optional(Schema.Boolean), + canEdit: Schema.optional(Schema.Boolean), }); const SourceRemoveResponse = Schema.Struct({ diff --git a/packages/core/config/src/index.ts b/packages/core/config/src/index.ts index 0fd66d9d43..e6487bb741 100644 --- a/packages/core/config/src/index.ts +++ b/packages/core/config/src/index.ts @@ -20,5 +20,3 @@ export { addSecretToConfig, removeSecretFromConfig, } from "./write"; - -export { withConfigFile } from "./config-store"; diff --git a/packages/core/sdk/src/sources.ts b/packages/core/sdk/src/sources.ts index fa55c5d9cd..dabde429d9 100644 --- a/packages/core/sdk/src/sources.ts +++ b/packages/core/sdk/src/sources.ts @@ -17,6 +17,8 @@ export class Source extends Schema.Class("Source")({ canRemove: Schema.optional(Schema.Boolean), /** Whether the source supports refresh */ canRefresh: Schema.optional(Schema.Boolean), + /** Whether the source supports editing (config changes) */ + canEdit: Schema.optional(Schema.Boolean), }) {} // --------------------------------------------------------------------------- diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 363ffe344a..74e8f2b956 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -29,6 +29,7 @@ "@apidevtools/swagger-parser": "^12.1.0", "@effect/platform": "catalog:", "@effect/platform-node": "catalog:", + "@executor/config": "workspace:*", "@executor/sdk": "workspace:*", "effect": "catalog:", "openapi-types": "^12.1.3", diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 0a91e5acaa..bff353998a 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -7,12 +7,14 @@ import { OpenApiExtractionError, } from "../sdk/errors"; import { SpecPreview } from "../sdk/preview"; +import { StoredSourceSchema } from "../sdk/stored-source"; // --------------------------------------------------------------------------- // Params // --------------------------------------------------------------------------- const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); +const namespaceParam = HttpApiSchema.param("namespace", Schema.String); // --------------------------------------------------------------------------- // Payloads @@ -31,6 +33,17 @@ const PreviewSpecPayload = Schema.Struct({ spec: Schema.String, }); +const UpdateSourcePayload = Schema.Struct({ + baseUrl: Schema.optional(Schema.String), + headers: Schema.optional( + Schema.Record({ key: Schema.String, value: Schema.Unknown }), + ), +}); + +const UpdateSourceResponse = Schema.Struct({ + updated: Schema.Boolean, +}); + // --------------------------------------------------------------------------- // Responses // --------------------------------------------------------------------------- @@ -70,4 +83,13 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") .addError(ParseError) .addError(ExtractionError), ) + .add( + HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}` + .addSuccess(Schema.NullOr(StoredSourceSchema)), + ) + .add( + HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}` + .setPayload(UpdateSourcePayload) + .addSuccess(UpdateSourceResponse), + ) {} diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index eb41d41638..b6941e165e 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -2,7 +2,7 @@ import { HttpApiBuilder } from "@effect/platform"; import { Context, Effect } from "effect"; import { addGroup } from "@executor/api"; -import type { OpenApiPluginExtension, HeaderValue } from "../sdk/plugin"; +import type { OpenApiPluginExtension, HeaderValue, OpenApiUpdateSourceInput } from "../sdk/plugin"; import { OpenApiGroup } from "./group"; // --------------------------------------------------------------------------- @@ -49,5 +49,20 @@ export const OpenApiHandlers = HttpApiBuilder.group( }; }).pipe(Effect.orDie), ) - , + .handle("getSource", ({ path }) => + Effect.gen(function* () { + const ext = yield* OpenApiExtensionService; + return yield* ext.getSource(path.namespace); + }).pipe(Effect.orDie), + ) + .handle("updateSource", ({ path, payload }) => + Effect.gen(function* () { + const ext = yield* OpenApiExtensionService; + yield* ext.updateSource(path.namespace, { + baseUrl: payload.baseUrl, + headers: payload.headers as Record | undefined, + } as OpenApiUpdateSourceInput); + return { updated: true }; + }).pipe(Effect.orDie), + ), ); diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index bb84885b53..01f2874f37 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -1,13 +1,153 @@ -export default function EditOpenApiSource(props: { +import { useState } from "react"; +import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; +import { openApiSourceAtom, updateOpenApiSource } from "./atoms"; +import { useScope } from "@executor/react/api/scope-context"; +import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; +import { + SecretHeaderAuthRow, + headerValueToState, + headersFromState, + type HeaderState, +} from "@executor/react/plugins/secret-header-auth"; +import { Button } from "@executor/react/components/button"; +import { Input } from "@executor/react/components/input"; +import { Label } from "@executor/react/components/label"; +import { Badge } from "@executor/react/components/badge"; +import type { StoredSourceSchemaType } from "../sdk/stored-source"; + +// --------------------------------------------------------------------------- +// Edit form +// --------------------------------------------------------------------------- + +function EditForm(props: { sourceId: string; + initial: StoredSourceSchemaType; onSave: () => void; }) { + const scopeId = useScope(); + const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" }); + const refreshSource = useAtomRefresh(openApiSourceAtom(scopeId, props.sourceId)); + const secretList = useSecretPickerSecrets(); + + const [baseUrl, setBaseUrl] = useState(props.initial.config.baseUrl ?? ""); + const [headers, setHeaders] = useState(() => + Object.entries(props.initial.config.headers ?? {}).map(([name, value]) => + headerValueToState(name, value), + ), + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [dirty, setDirty] = useState(false); + + const updateHeader = (index: number, update: Partial) => { + setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, ...update } : h))); + setDirty(true); + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + await doUpdate({ + path: { scopeId, namespace: props.sourceId }, + payload: { + baseUrl: baseUrl.trim() || undefined, + headers: headersFromState(headers), + }, + }); + refreshSource(); + setDirty(false); + props.onSave(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update source"); + } finally { + setSaving(false); + } + }; + return ( -
-

Edit OpenAPI Source

-

Source: {props.sourceId}

- {/* TODO: show spec info, auth config, operation list */} - +
+
+

Edit OpenAPI Source

+

+ Update the base URL and authentication headers for this source. +

+
+ +
+
+

{props.sourceId}

+
+ OpenAPI +
+ +
+ + { setBaseUrl((e.target as HTMLInputElement).value); setDirty(true); }} + placeholder="https://api.example.com" + className="font-mono text-sm" + /> +
+ +
+ + {headers.map((h, i) => ( + updateHeader(i, update)} + onSelectSecret={(secretId) => updateHeader(i, { secretId })} + onRemove={() => { setHeaders((prev) => prev.filter((_, j) => j !== i)); setDirty(true); }} + existingSecrets={secretList} + /> + ))} + +
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
); } + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function EditOpenApiSource(props: { + sourceId: string; + onSave: () => void; +}) { + const scopeId = useScope(); + const sourceResult = useAtomValue(openApiSourceAtom(scopeId, props.sourceId)); + + if (!Result.isSuccess(sourceResult) || !sourceResult.value) { + return ( +
+
+

Edit OpenAPI Source

+

Loading configuration…

+
+
+ ); + } + + return ; +} diff --git a/packages/plugins/openapi/src/react/atoms.ts b/packages/plugins/openapi/src/react/atoms.ts index a8bc3d2768..a39c090dc9 100644 --- a/packages/plugins/openapi/src/react/atoms.ts +++ b/packages/plugins/openapi/src/react/atoms.ts @@ -1,5 +1,16 @@ +import type { ScopeId } from "@executor/sdk"; import { OpenApiClient } from "./client"; +// --------------------------------------------------------------------------- +// Query atoms +// --------------------------------------------------------------------------- + +export const openApiSourceAtom = (scopeId: ScopeId, namespace: string) => + OpenApiClient.query("openapi", "getSource", { + path: { scopeId, namespace }, + timeToLive: "15 seconds", + }); + // --------------------------------------------------------------------------- // Mutation atoms // --------------------------------------------------------------------------- @@ -10,3 +21,5 @@ export const previewOpenApiSpec = OpenApiClient.mutation( ); export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec"); + +export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource"); diff --git a/packages/plugins/openapi/src/sdk/config-file-store.ts b/packages/plugins/openapi/src/sdk/config-file-store.ts new file mode 100644 index 0000000000..e8a21eb805 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/config-file-store.ts @@ -0,0 +1,69 @@ +/** + * Config-file wrapper for OpenApiOperationStore. + * + * Decorates an underlying store so that `putSource` and `removeSource` also + * write to executor.jsonc. + */ + +import { Effect } from "effect"; +import { FileSystem } from "@effect/platform"; +import type { Layer } from "effect"; + +import { + addSourceToConfig, + removeSourceFromConfig, + SECRET_REF_PREFIX, +} from "@executor/config"; +import type { SourceConfig as ConfigFileSourceConfig, ConfigHeaderValue } from "@executor/config"; + +import type { OpenApiOperationStore, StoredSource } from "./operation-store"; + +type PluginHeaderValue = string | { readonly secretId: string; readonly prefix?: string }; + +const translateSecretHeaders = ( + headers: Readonly> | undefined, +): Record | undefined => { + if (!headers) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value === "string") { + result[key] = value; + continue; + } + const ref = `${SECRET_REF_PREFIX}${value.secretId}`; + result[key] = value.prefix ? { value: ref, prefix: value.prefix } : ref; + } + return result; +}; + +const toSourceConfig = (source: StoredSource): ConfigFileSourceConfig => ({ + kind: "openapi", + spec: source.config.spec, + baseUrl: source.config.baseUrl, + namespace: source.namespace, + headers: translateSecretHeaders(source.config.headers), +}); + +export const withConfigFile = ( + inner: OpenApiOperationStore, + configPath: string, + fsLayer: Layer.Layer, +): OpenApiOperationStore => ({ + ...inner, + putSource: (source) => + Effect.gen(function* () { + yield* inner.putSource(source); + yield* addSourceToConfig(configPath, toSourceConfig(source)).pipe( + Effect.provide(fsLayer), + Effect.catchAll(() => Effect.void), + ); + }), + removeSource: (namespace) => + Effect.gen(function* () { + yield* inner.removeSource(namespace); + yield* removeSourceFromConfig(configPath, namespace).pipe( + Effect.provide(fsLayer), + Effect.catchAll(() => Effect.void), + ); + }), +}); diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts index 235578841b..3c49ea5f51 100644 --- a/packages/plugins/openapi/src/sdk/index.ts +++ b/packages/plugins/openapi/src/sdk/index.ts @@ -16,6 +16,7 @@ export { makeKvOperationStore, makeInMemoryOperationStore, } from "./kv-operation-store"; +export { withConfigFile } from "./config-file-store"; export { previewSpec, SecurityScheme, diff --git a/packages/plugins/openapi/src/sdk/kv-operation-store.ts b/packages/plugins/openapi/src/sdk/kv-operation-store.ts index b2fc7c94c2..a70851711d 100644 --- a/packages/plugins/openapi/src/sdk/kv-operation-store.ts +++ b/packages/plugins/openapi/src/sdk/kv-operation-store.ts @@ -8,7 +8,8 @@ import { Effect, Schema } from "effect"; import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk"; import type { OpenApiOperationStore, StoredOperation, StoredSource } from "./operation-store"; -import { OperationBinding, InvocationConfig, HeaderValue } from "./types"; +import { OperationBinding, InvocationConfig } from "./types"; +import { StoredSourceSchema } from "./stored-source"; // --------------------------------------------------------------------------- // Stored schemas @@ -23,18 +24,6 @@ class StoredEntry extends Schema.Class("StoredEntry")({ const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry)); const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry)); -const StoredSourceSchema = Schema.Struct({ - namespace: Schema.String, - name: Schema.String, - config: Schema.Struct({ - spec: Schema.String, - baseUrl: Schema.optional(Schema.String), - namespace: Schema.optional(Schema.String), - headers: Schema.optional( - Schema.Record({ key: Schema.String, value: HeaderValue }), - ), - }), -}); const encodeSource = Schema.encodeSync(Schema.parseJson(StoredSourceSchema)); const decodeSource = Schema.decodeUnknownSync(Schema.parseJson(StoredSourceSchema)); @@ -112,6 +101,21 @@ const makeStore = ( const entries = yield* sources.list(); return entries.map((e) => decodeSource(e.value) as StoredSource); }), + + getSource: (namespace) => + Effect.gen(function* () { + const raw = yield* sources.get(namespace); + if (!raw) return null; + return decodeSource(raw) as StoredSource; + }), + + getSourceConfig: (namespace) => + Effect.gen(function* () { + const raw = yield* sources.get(namespace); + if (!raw) return null; + const source = decodeSource(raw) as StoredSource; + return source.config; + }), }); }; diff --git a/packages/plugins/openapi/src/sdk/operation-store.ts b/packages/plugins/openapi/src/sdk/operation-store.ts index 4f59344fbf..5ad919865f 100644 --- a/packages/plugins/openapi/src/sdk/operation-store.ts +++ b/packages/plugins/openapi/src/sdk/operation-store.ts @@ -45,4 +45,12 @@ export interface OpenApiOperationStore { readonly removeSource: (namespace: string) => Effect.Effect; readonly listSources: () => Effect.Effect; + + readonly getSource: ( + namespace: string, + ) => Effect.Effect; + + readonly getSourceConfig: ( + namespace: string, + ) => Effect.Effect; } diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index a3cf98d78e..bca8fa14ec 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -19,7 +19,7 @@ import { extract } from "./extract"; import { compileToolDefinitions, type ToolDefinition } from "./definitions"; import { makeOpenApiInvoker } from "./invoke"; import { resolveBaseUrl } from "./openapi-utils"; -import type { OpenApiOperationStore } from "./operation-store"; +import type { OpenApiOperationStore, StoredSource } from "./operation-store"; import { makeInMemoryOperationStore } from "./kv-operation-store"; import { previewSpec, SpecPreview } from "./preview"; import { @@ -48,6 +48,11 @@ export interface OpenApiSpecConfig { // Plugin extension // --------------------------------------------------------------------------- +export interface OpenApiUpdateSourceInput { + readonly baseUrl?: string; + readonly headers?: Record; +} + export interface OpenApiPluginExtension { /** Preview a spec without registering — returns metadata, auth strategies, header presets */ readonly previewSpec: (specText: string) => Effect.Effect; @@ -59,6 +64,17 @@ export interface OpenApiPluginExtension { /** Remove all tools from a previously added spec by namespace */ readonly removeSpec: (namespace: string) => Effect.Effect; + + /** Fetch the full stored source by namespace (or null if missing) */ + readonly getSource: ( + namespace: string, + ) => Effect.Effect; + + /** Update config (baseUrl, headers) for an existing OpenAPI source */ + readonly updateSource: ( + namespace: string, + input: OpenApiUpdateSourceInput, + ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -190,6 +206,7 @@ export const openApiPlugin = (options?: { runtime: false, canRemove: true, canRefresh: false, + canEdit: true, }), ), ), @@ -343,6 +360,48 @@ export const openApiPlugin = (options?: { } yield* operationStore.removeSource(namespace); }), + + getSource: (namespace: string) => + operationStore.getSource(namespace), + + updateSource: (namespace: string, input: OpenApiUpdateSourceInput) => + Effect.gen(function* () { + const existingSource = yield* operationStore.getSourceConfig(namespace); + if (!existingSource) return; + + const updatedConfig = { + ...existingSource, + ...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}), + ...(input.headers !== undefined ? { headers: input.headers as Record } : {}), + }; + + const newInvocationConfig = new InvocationConfig({ + baseUrl: updatedConfig.baseUrl ?? resolveBaseUrl([]), + headers: (updatedConfig.headers ?? {}) as Record, + }); + + const toolIds = yield* operationStore.listByNamespace(namespace); + for (const toolId of toolIds) { + const entry = yield* operationStore.get(toolId); + if (entry) { + yield* operationStore.put([{ + toolId, + namespace, + binding: entry.binding, + config: newInvocationConfig, + }]); + } + } + + const sources = yield* operationStore.listSources(); + const existingMeta = sources.find((s) => s.namespace === namespace); + + yield* operationStore.putSource({ + namespace, + name: existingMeta?.name ?? namespace, + config: updatedConfig, + }); + }), }, close: () => runtimeTools.close(), diff --git a/packages/plugins/openapi/src/sdk/stored-source.ts b/packages/plugins/openapi/src/sdk/stored-source.ts new file mode 100644 index 0000000000..2e232b53c0 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/stored-source.ts @@ -0,0 +1,25 @@ +import { Schema } from "effect"; + +import { HeaderValue } from "./types"; + +// --------------------------------------------------------------------------- +// Stored source — the shape persisted by the operation store and exposed +// via the getSource HTTP endpoint. +// --------------------------------------------------------------------------- + +export class StoredSourceSchema extends Schema.Class( + "OpenApiStoredSource", +)({ + namespace: Schema.String, + name: Schema.String, + config: Schema.Struct({ + spec: Schema.String, + baseUrl: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), + headers: Schema.optional( + Schema.Record({ key: Schema.String, value: HeaderValue }), + ), + }), +}) {} + +export type StoredSourceSchemaType = typeof StoredSourceSchema.Type; diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 1b89875c30..a3c3ea7c35 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -76,3 +76,4 @@ export const removeSource = ExecutorApiClient.mutation("sources", "remove"); export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); export const detectSource = ExecutorApiClient.mutation("sources", "detect"); + diff --git a/packages/react/src/pages/source-detail.tsx b/packages/react/src/pages/source-detail.tsx index a5f6c579ab..dbd0e52cd3 100644 --- a/packages/react/src/pages/source-detail.tsx +++ b/packages/react/src/pages/source-detail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { Suspense, useEffect, useMemo, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; import { sourceToolsAtom, sourcesAtom, sourceAtom, removeSource, refreshSource } from "../api/atoms"; @@ -6,9 +6,13 @@ import { ToolTree } from "../components/tool-tree"; import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; import type { ToolSummary } from "../components/tool-tree"; import { useScope } from "../hooks/use-scope"; +import type { SourcePlugin } from "../plugins/source-plugin"; -export function SourceDetailPage(props: { namespace: string }) { - const { namespace } = props; +export function SourceDetailPage(props: { + namespace: string; + sourcePlugins?: readonly SourcePlugin[]; +}) { + const { namespace, sourcePlugins } = props; const scopeId = useScope(); const source = useAtomValue(sourceAtom(namespace, scopeId)); const tools = useAtomValue(sourceToolsAtom(namespace, scopeId)); @@ -30,10 +34,18 @@ export function SourceDetailPage(props: { namespace: string }) { const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [editing, setEditing] = useState(false); const sourceData = Result.isSuccess(source) ? source.value : null; const canRefresh = sourceData ? (sourceData.canRefresh ?? true) : false; const canRemove = sourceData ? (sourceData.canRemove ?? true) : false; + const canEdit = sourceData ? (sourceData.canEdit ?? false) : false; + + // Find the plugin edit component based on source kind + const editPlugin = useMemo(() => { + if (!sourceData || !sourcePlugins) return null; + return sourcePlugins.find((p) => p.key === sourceData.kind) ?? null; + }, [sourceData, sourcePlugins]); const sourceTools: ToolSummary[] = useMemo(() => { if (!Result.isSuccess(tools)) return []; @@ -77,6 +89,12 @@ export function SourceDetailPage(props: { namespace: string }) { } }; + const handleEditSave = () => { + setEditing(false); + refreshSources(); + refreshTools(); + }; + return (
{/* Header bar */} @@ -93,7 +111,7 @@ export function SourceDetailPage(props: { namespace: string }) { {sourceData?.kind ?? "source"} - {Result.isSuccess(tools) && ( + {Result.isSuccess(tools) && !editing && ( {sourceTools.length} {sourceTools.length === 1 ? "tool" : "tools"} @@ -101,18 +119,38 @@ export function SourceDetailPage(props: { namespace: string }) {
- {canRefresh && ( + {canEdit && editPlugin && !editing && ( + + )} + + {editing && ( + + )} + + {canRefresh && !editing && ( )} - {canRemove && (confirmDelete ? ( + {canRemove && !editing && (confirmDelete ? (
Confirm? @@ -131,7 +169,7 @@ export function SourceDetailPage(props: { namespace: string }) { disabled={deleting} className="inline-flex items-center rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-50" > - {deleting ? "Deleting…" : "Delete"} + {deleting ? "Deleting..." : "Delete"}
) : ( @@ -146,41 +184,52 @@ export function SourceDetailPage(props: { namespace: string }) {
- {/* Content — split pane */} - {Result.match(tools, { - onInitial: () => ( -
Loading…
- ), - onFailure: () => ( -
Failed to load tools
- ), - onSuccess: () => ( -
- {/* Left: tool tree */} -
- -
- - {/* Right: tool detail */} -
- {selectedTool ? ( - +
+ Loading...
}> + + +
+
+ ) : ( + /* Content -- split pane */ + Result.match(tools, { + onInitial: () => ( +
Loading...
+ ), + onFailure: () => ( +
Failed to load tools
+ ), + onSuccess: () => ( +
+ {/* Left: tool tree */} +
+ - ) : ( - 0} /> - )} +
+ + {/* Right: tool detail */} +
+ {selectedTool ? ( + + ) : ( + 0} /> + )} +
- - ), - })} + ), + }) + )} ); } diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index 5afd588906..a9aca48ced 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -209,6 +209,59 @@ function HeaderValuePreview(props: { ); } +// --------------------------------------------------------------------------- +// Header state helpers — shared by edit forms +// --------------------------------------------------------------------------- + +export type HeaderState = { + name: string; + secretId: string | null; + prefix?: string; + presetKey?: string; + fromPreset?: boolean; +}; + +export function matchPresetKey(name: string, prefix?: string): string { + const preset = + defaultHeaderAuthPresets.find((p) => p.name === name && p.prefix === prefix) + ?? defaultHeaderAuthPresets.find((p) => p.name === name && p.prefix === undefined); + return preset?.key ?? "custom"; +} + +export function headerValueToState( + name: string, + value: { secretId: string; prefix?: string } | string, +): HeaderState { + if (typeof value === "string") { + return { name, secretId: null, presetKey: matchPresetKey(name, undefined) }; + } + return { + name, + secretId: value.secretId, + prefix: value.prefix, + presetKey: matchPresetKey(name, value.prefix), + }; +} + +export function headersFromState( + entries: readonly HeaderState[], +): Record { + const result: Record = {}; + for (const entry of entries) { + const name = entry.name.trim(); + if (!name || !entry.secretId) continue; + result[name] = { + secretId: entry.secretId, + ...(entry.prefix ? { prefix: entry.prefix } : {}), + }; + } + return result; +} + +// --------------------------------------------------------------------------- +// Secret header auth row +// --------------------------------------------------------------------------- + export function SecretHeaderAuthRow(props: { name: string; prefix?: string;