Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,16 @@ apps/desktop/resources/

# cloud local dev database
.pglite
apps/cloud/.dev-db/
.claude/
.nitro/
.output/
.tanstack/
.env*
!.env.example
!.env.example

# local user config
apps/local/executor.jsonc

# VS Code (per-workspace user settings)
.vscode/mcp.json
13 changes: 12 additions & 1 deletion apps/cloud/src/routes/sources.$namespace.tsx
Original file line number Diff line number Diff line change
@@ -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 <SourceDetailPage namespace={namespace} />;
return <SourceDetailPage namespace={namespace} sourcePlugins={sourcePlugins} />;
},
});
13 changes: 12 additions & 1 deletion apps/local/src/routes/sources.$namespace.tsx
Original file line number Diff line number Diff line change
@@ -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 <SourceDetailPage namespace={namespace} />;
return <SourceDetailPage namespace={namespace} sourcePlugins={sourcePlugins} />;
},
});
10 changes: 6 additions & 4 deletions apps/local/src/server/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -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,
Expand All @@ -75,7 +77,7 @@ const createLocalPlugins = (
),
}),
graphqlPlugin({
operationStore: withConfigFile.graphql(
operationStore: withGraphqlConfigFile(
makeKvGraphqlOperationStore(scopedKv, "graphql"),
configPath,
fsLayer,
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/api/src/handlers/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const SourcesHandlers = HttpApiBuilder.group(
runtime: s.runtime,
canRemove: s.canRemove,
canRefresh: s.canRefresh,
canEdit: s.canEdit,
}));
}),
)
Expand Down
1 change: 1 addition & 0 deletions packages/core/api/src/sources/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 0 additions & 2 deletions packages/core/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,3 @@ export {
addSecretToConfig,
removeSecretFromConfig,
} from "./write";

export { withConfigFile } from "./config-store";
2 changes: 2 additions & 0 deletions packages/core/sdk/src/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export class Source extends Schema.Class<Source>("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),
}) {}

// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions packages/plugins/openapi/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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),
)
{}
19 changes: 17 additions & 2 deletions packages/plugins/openapi/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<string, HeaderValue> | undefined,
} as OpenApiUpdateSourceInput);
return { updated: true };
}).pipe(Effect.orDie),
),
);
152 changes: 146 additions & 6 deletions packages/plugins/openapi/src/react/EditOpenApiSource.tsx
Original file line number Diff line number Diff line change
@@ -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<HeaderState[]>(() =>
Object.entries(props.initial.config.headers ?? {}).map(([name, value]) =>
headerValueToState(name, value),
),
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);

const updateHeader = (index: number, update: Partial<HeaderState>) => {
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 (
<div>
<h3>Edit OpenAPI Source</h3>
<p>Source: {props.sourceId}</p>
{/* TODO: show spec info, auth config, operation list */}
<button onClick={props.onSave}>Save</button>
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Edit OpenAPI Source</h1>
<p className="mt-1 text-[13px] text-muted-foreground">
Update the base URL and authentication headers for this source.
</p>
</div>

<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-card-foreground">{props.sourceId}</p>
</div>
<Badge variant="secondary" className="text-[10px]">OpenAPI</Badge>
</div>

<section className="space-y-2">
<Label>Base URL</Label>
<Input
value={baseUrl}
onChange={(e) => { setBaseUrl((e.target as HTMLInputElement).value); setDirty(true); }}
placeholder="https://api.example.com"
className="font-mono text-sm"
/>
</section>

<section className="space-y-2.5">
<Label>Headers</Label>
{headers.map((h, i) => (
<SecretHeaderAuthRow
key={i}
name={h.name}
prefix={h.prefix}
presetKey={h.presetKey}
secretId={h.secretId}
onChange={(update) => updateHeader(i, update)}
onSelectSecret={(secretId) => updateHeader(i, { secretId })}
onRemove={() => { setHeaders((prev) => prev.filter((_, j) => j !== i)); setDirty(true); }}
existingSecrets={secretList}
/>
))}
<Button variant="outline" size="sm" className="w-full border-dashed" onClick={() => { setHeaders((prev) => [...prev, { name: "", secretId: null }]); setDirty(true); }}>
+ Add header
</Button>
</section>

{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
<p className="text-[12px] text-destructive">{error}</p>
</div>
)}

<div className="flex items-center justify-between border-t border-border pt-4">
<Button variant="ghost" onClick={props.onSave}>Cancel</Button>
<Button onClick={handleSave} disabled={!dirty || saving}>
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
);
}

// ---------------------------------------------------------------------------
// 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 (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Edit OpenAPI Source</h1>
<p className="mt-1 text-[13px] text-muted-foreground">Loading configuration…</p>
</div>
</div>
);
}

return <EditForm sourceId={props.sourceId} initial={sourceResult.value} onSave={props.onSave} />;
}
Loading