History

Add console support for custom app tools

Rhys Sullivan committed 4 months ago

Commit 0aa658d1 parent f59577130 files · +1935 −34Browse files

Unified · Split

30 files
  1. bun.lock +14 −0
  2. e2e/fixtures/custom-tools-git/custom-tools-shas.txt +2 −0
  3. e2e/fixtures/custom-tools-git/custom-tools-v1.pack +0 −0
  4. e2e/fixtures/custom-tools-git/custom-tools-v2.pack +0 −0
  5. e2e/scenarios/custom-tools.test.ts +377 −0
  6. packages/core/sdk/src/connections.test.ts +62 −1
  7. packages/core/sdk/src/executor.ts +5 −1
  8. packages/plugins/apps/package.json +25 −1
  9. packages/plugins/apps/src/git-client/url-security.ts +8 −2
  10. packages/plugins/apps/src/pipeline/publish.test.ts +12 −1
  11. packages/plugins/apps/src/plugin/apps-plugin.test.ts +19 −3
  12. packages/plugins/apps/src/plugin/apps-plugin.ts +32 −10
  13. packages/plugins/apps/src/plugin/store.test.ts +21 −0
  14. packages/plugins/apps/src/plugin/store.ts +19 −1
  15. packages/plugins/apps/src/react/AddCustomToolsSource.tsx +264 −0
  16. packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx +287 −0
  17. packages/plugins/apps/src/react/custom-tools-client.ts +311 −0
  18. packages/plugins/apps/src/react/custom-tools-client.web.test.ts +174 −0
  19. packages/plugins/apps/src/react/index.ts +2 −0
  20. packages/plugins/apps/src/react/plugin-client.tsx +16 −0
  21. packages/plugins/apps/src/react/source-panel-model.ts +88 −0
  22. packages/plugins/apps/src/react/source-plugin.tsx +36 −0
  23. packages/plugins/apps/src/source/app-source.test.ts +6 −0
  24. packages/plugins/apps/tsconfig.json +2 −1
  25. packages/react/src/components/json-schema-form.tsx +31 −8
  26. packages/react/src/lib/integration-detail-tabs.ts +16 −0
  27. packages/react/src/pages/integration-add.tsx +11 −1
  28. packages/react/src/pages/integration-detail.tsx +41 −5
  29. packages/react/src/pages/integrations.tsx +40 −0
  30. packages/react/src/routes/integrations.$namespace.tsx +16 −1
bun.lock+14−0
@@ -801,16 +801,30 @@ · 800 unmodified lines
801801 "zod": "4.3.6",
802802 },
803803 "devDependencies": {
804+ "@effect/atom-react": "catalog:",
804805 "@effect/vitest": "catalog:",
806+ "@executor-js/react": "workspace:*",
805807 "@types/node": "catalog:",
808+ "@types/react": "catalog:",
806809 "bun-types": "catalog:",
807810 "effect": "catalog:",
811+ "react": "catalog:",
808812 "tsup": "catalog:",
809813 "vitest": "catalog:",
810814 },
811815 "peerDependencies": {
816+ "@effect/atom-react": "catalog:",
817+ "@executor-js/react": "workspace:*",
818+ "@tanstack/react-router": "catalog:",
812819 "effect": "catalog:",
820+ "react": "catalog:",
813821 },
822+ "optionalPeers": [
823+ "@effect/atom-react",
824+ "@executor-js/react",
825+ "@tanstack/react-router",
826+ "react",
827+ ],
814828 },
815829 "packages/plugins/desktop-settings": {
816830 "name": "@executor-js/plugin-desktop-settings",
e2e/fixtures/custom-tools-git/custom-tools-shas.txt+2−0
@@ -0,0 +1,2 @@
1+82609ee4ee2acbeb9056ee96a21b24bcee573aac
2+0747e7274d47ee678e314020d3e1f670b57fb8fd
e2e/fixtures/custom-tools-git/custom-tools-v1.pack+0−0

Binary file not shown.

e2e/fixtures/custom-tools-git/custom-tools-v2.pack+0−0

Binary file not shown.

e2e/scenarios/custom-tools.test.ts+377−0
@@ -0,0 +1,377 @@
1+/* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e fixture server and raw HTTP assertions */
2+import { readFile } from "node:fs/promises";
3+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4+import { join } from "node:path";
5+
6+import { expect } from "@effect/vitest";
7+import { Effect } from "effect";
8+
9+import { scenario } from "../src/scenario";
10+import { Api, Browser, Target } from "../src/services";
11+import type { Identity, Target as TargetShape } from "../src/target";
12+import type { BrowserSurface } from "../src/surfaces/browser";
13+
14+const textEncoder = new TextEncoder();
15+
16+const concat = (parts: readonly Uint8Array[]): Uint8Array => {
17+ const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
18+ let offset = 0;
19+ for (const part of parts) {
20+ out.set(part, offset);
21+ offset += part.length;
22+ }
23+ return out;
24+};
25+
26+const pktLine = (payload: string | Uint8Array): Uint8Array => {
27+ const body = typeof payload === "string" ? textEncoder.encode(payload) : payload;
28+ const header = textEncoder.encode((body.length + 4).toString(16).padStart(4, "0"));
29+ return concat([header, body]);
30+};
31+
32+const FLUSH = textEncoder.encode("0000");
33+
34+const sideBand = (bytes: Uint8Array): Uint8Array => {
35+ const chunks: Uint8Array[] = [pktLine("NAK\n")];
36+ for (let offset = 0; offset < bytes.length; offset += 60_000) {
37+ const chunk = bytes.subarray(offset, offset + 60_000);
38+ const payload = new Uint8Array(chunk.length + 1);
39+ payload[0] = 1;
40+ payload.set(chunk, 1);
41+ chunks.push(pktLine(payload));
42+ }
43+ chunks.push(FLUSH);
44+ return concat(chunks);
45+};
46+
47+const advertisement = (sha: string): Uint8Array =>
48+ concat([
49+ pktLine("# service=git-upload-pack\n"),
50+ FLUSH,
51+ pktLine(
52+ `${sha} HEAD\0symref=HEAD:refs/heads/main multi_ack side-band-64k thin-pack ofs-delta\n`,
53+ ),
54+ pktLine(`${sha} refs/heads/main\n`),
55+ FLUSH,
56+ ]);
57+
58+const readFixture = async () => {
59+ const dir = join(import.meta.dirname, "..", "fixtures", "custom-tools-git");
60+ const [shas, pack1, pack2] = await Promise.all([
61+ readFile(join(dir, "custom-tools-shas.txt"), "utf8"),
62+ readFile(join(dir, "custom-tools-v1.pack")),
63+ readFile(join(dir, "custom-tools-v2.pack")),
64+ ]);
65+ const [sha1, sha2] = shas.trim().split("\n");
66+ return { sha1: sha1!, sha2: sha2!, pack1, pack2 };
67+};
68+
69+const fixtureGitServer = Effect.acquireRelease(
70+ Effect.promise(async () => {
71+ const fixture = await readFixture();
72+ let current = { sha: fixture.sha1, pack: new Uint8Array(fixture.pack1) };
73+ let packRequests = 0;
74+ const server = createServer((request: IncomingMessage, response: ServerResponse) => {
75+ if (request.url === "/repo.git/info/refs?service=git-upload-pack") {
76+ response.writeHead(200, {
77+ "content-type": "application/x-git-upload-pack-advertisement",
78+ });
79+ response.end(advertisement(current.sha));
80+ return;
81+ }
82+ if (request.url === "/repo.git/git-upload-pack" && request.method === "POST") {
83+ packRequests += 1;
84+ response.writeHead(200, { "content-type": "application/x-git-upload-pack-result" });
85+ response.end(sideBand(current.pack));
86+ return;
87+ }
88+ response.writeHead(404, { "content-type": "text/plain" });
89+ response.end("not found");
90+ });
91+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
92+ const address = server.address();
93+ if (!address || typeof address === "string") throw new Error("fixture server did not bind");
94+ return {
95+ url: `http://127.0.0.1:${address.port}/repo.git`,
96+ advance: () => {
97+ current = { sha: fixture.sha2, pack: new Uint8Array(fixture.pack2) };
98+ },
99+ packRequests: () => packRequests,
100+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
101+ };
102+ }),
103+ (server) => Effect.promise(() => server.close()).pipe(Effect.ignore),
104+);
105+
106+interface ToolRow {
107+ readonly address: string;
108+ readonly name: string;
109+ readonly integration: string;
110+}
111+
112+interface ExecuteResponse {
113+ readonly status: "completed" | "paused";
114+ readonly text: string;
115+ readonly structured: unknown;
116+ readonly isError?: boolean;
117+}
118+
119+const authHeaders = async (
120+ target: TargetShape,
121+ identity: Identity,
122+): Promise<Record<string, string>> => {
123+ if (identity.headers) return identity.headers;
124+ const credentials = identity.credentials;
125+ if (!credentials) throw new Error(`identity ${identity.label} has no credentials`);
126+ const response = await fetch(new URL("/api/auth/sign-in/email", target.baseUrl), {
127+ method: "POST",
128+ headers: { "content-type": "application/json", origin: new URL(target.baseUrl).origin },
129+ body: JSON.stringify(credentials),
130+ redirect: "manual",
131+ });
132+ const cookie = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
133+ if (!cookie) throw new Error(`sign-in set no cookie (${response.status})`);
134+ return { cookie };
135+};
136+
137+const request = async <T>(
138+ target: TargetShape,
139+ identity: Identity,
140+ path: string,
141+ init: RequestInit = {},
142+ expectedStatus = 200,
143+): Promise<{ readonly body: T; readonly text: string }> => {
144+ const headers = new Headers(init.headers);
145+ headers.set("origin", new URL(target.baseUrl).origin);
146+ for (const [name, value] of Object.entries(await authHeaders(target, identity))) {
147+ headers.set(name, value);
148+ }
149+ if (init.body !== undefined && !headers.has("content-type")) {
150+ headers.set("content-type", "application/json");
151+ }
152+ const response = await fetch(new URL(path, target.baseUrl), { ...init, headers });
153+ const text = await response.text();
154+ expect(response.status, `${init.method ?? "GET"} ${path}: ${text}`).toBe(expectedStatus);
155+ return { body: (text.length > 0 ? JSON.parse(text) : null) as T, text };
156+};
157+
158+const postJson = <T>(
159+ target: TargetShape,
160+ identity: Identity,
161+ path: string,
162+ body?: unknown,
163+ expectedStatus = 200,
164+): Promise<{ readonly body: T; readonly text: string }> =>
165+ request<T>(
166+ target,
167+ identity,
168+ path,
169+ {
170+ method: "POST",
171+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
172+ },
173+ expectedStatus,
174+ );
175+
176+const deletePath = (
177+ target: TargetShape,
178+ identity: Identity,
179+ path: string,
180+): Promise<{ readonly body: { readonly removed?: boolean }; readonly text: string }> =>
181+ request(target, identity, path, { method: "DELETE" });
182+
183+const execute = (target: TargetShape, identity: Identity, code: string): Promise<ExecuteResponse> =>
184+ postJson<ExecuteResponse>(target, identity, "/api/executions", {
185+ code,
186+ autoApprove: true,
187+ }).then((response) => response.body);
188+
189+const addSourceThroughConsole = (input: {
190+ readonly target: TargetShape;
191+ readonly browser: BrowserSurface;
192+ readonly identity: Identity;
193+ readonly sourceUrl: string;
194+}) =>
195+ input.browser.session(input.identity, async ({ page, step }) => {
196+ await step("Open the integrations page", async () => {
197+ await page.goto(new URL("/integrations", input.target.baseUrl).toString(), {
198+ waitUntil: "networkidle",
199+ });
200+ });
201+
202+ await step("Detect the Git repository as custom tools", async () => {
203+ await page.getByRole("button", { name: "Connect", exact: true }).click();
204+ const dialog = page.getByRole("dialog");
205+ await dialog.getByRole("textbox").fill(input.sourceUrl);
206+ await dialog.getByRole("button", { name: "Detect" }).click();
207+ await page.getByRole("heading", { name: "Add custom tools" }).waitFor();
208+ });
209+
210+ await step("Sync the custom tools source", async () => {
211+ await page.locator('input[type="password"]').fill("fixture-token");
212+ await page.getByRole("button", { name: "Sync source" }).click();
213+ await page.waitForURL(/\/integrations\/apps(?:\?|$)/, { timeout: 90_000 });
214+ await page.getByText("2 tools").waitFor({ timeout: 90_000 });
215+ });
216+ });
217+
218+const syncSourceInConsole = (input: {
219+ readonly target: TargetShape;
220+ readonly browser: BrowserSurface;
221+ readonly identity: Identity;
222+ readonly expectedNotice: string;
223+ readonly expectedToolCount: string;
224+}) =>
225+ input.browser.session(input.identity, async ({ page, step }) => {
226+ await step(`Sync custom tools and see ${input.expectedNotice}`, async () => {
227+ await page.goto(new URL("/integrations/apps?tab=source", input.target.baseUrl).toString(), {
228+ waitUntil: "networkidle",
229+ });
230+ await page.getByRole("button", { name: "Sync" }).click();
231+ await page.getByText(input.expectedNotice).waitFor({ timeout: 90_000 });
232+ await page
233+ .locator("p")
234+ .filter({ hasText: new RegExp(`^${input.expectedToolCount}$`) })
235+ .waitFor({ timeout: 90_000 });
236+ });
237+ });
238+
239+const removeSourceThroughConsole = (input: {
240+ readonly target: TargetShape;
241+ readonly browser: BrowserSurface;
242+ readonly identity: Identity;
243+}) =>
244+ input.browser.session(input.identity, async ({ page, step }) => {
245+ await step("Remove the custom tools source", async () => {
246+ await page.goto(new URL("/integrations/apps?tab=source", input.target.baseUrl).toString(), {
247+ waitUntil: "networkidle",
248+ });
249+ await page.getByRole("button", { name: "Remove" }).click();
250+ await page.getByRole("button", { name: "Remove source" }).click();
251+ await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 });
252+ });
253+ });
254+
255+scenario(
256+ "Custom tools · Git source syncs, invokes, refreshes, and removes",
257+ { timeout: 300_000 },
258+ Effect.scoped(
259+ Effect.gen(function* () {
260+ const target = yield* Target;
261+ if (target.name !== "selfhost") return;
262+ const browser = yield* Browser;
263+ yield* Api;
264+ const identity = yield* target.newIdentity();
265+ const git = yield* fixtureGitServer;
266+
267+ yield* Effect.ensuring(
268+ Effect.gen(function* () {
269+ const unauthorized = yield* Effect.promise(() =>
270+ fetch(new URL("/api/apps/sources/custom-tools/sync", target.baseUrl), {
271+ method: "POST",
272+ }),
273+ );
274+ expect(unauthorized.status, "sync requires authentication").toBe(401);
275+ yield* Effect.promise(() => unauthorized.text());
276+
277+ yield* addSourceThroughConsole({ target, browser, identity, sourceUrl: git.url });
278+
279+ const sources = yield* Effect.promise(() =>
280+ request<{
281+ readonly sources: readonly {
282+ readonly slug: string;
283+ readonly app: string;
284+ readonly config: { readonly kind: string; readonly url?: string };
285+ }[];
286+ }>(target, identity, "/api/apps/sources"),
287+ );
288+ expect(sources.text).not.toContain("fixture-token");
289+ const source = sources.body.sources.find((item) => item.app === "repo");
290+ expect(source).toMatchObject({
291+ app: "repo",
292+ config: { kind: "git", url: git.url },
293+ });
294+
295+ const tools = yield* Effect.promise(() =>
296+ request<readonly ToolRow[]>(target, identity, "/api/tools?integration=apps"),
297+ );
298+ expect(tools.body.map((tool) => tool.name).sort()).toEqual(["echo-tool", "static-tool"]);
299+ const echoTool = tools.body.find((tool) => tool.name === "echo-tool");
300+ expect(echoTool).toBeDefined();
301+
302+ const schema = yield* Effect.promise(() =>
303+ request<{
304+ readonly inputSchema: {
305+ readonly properties?: Record<
306+ string,
307+ { readonly enum?: readonly string[]; readonly default?: unknown }
308+ >;
309+ readonly required?: readonly string[];
310+ };
311+ }>(
312+ target,
313+ identity,
314+ `/api/tools/schema?address=${encodeURIComponent(echoTool!.address)}`,
315+ ),
316+ );
317+ expect(schema.body.inputSchema.properties?.apps).toMatchObject({
318+ enum: ["tools.apps.org.published"],
319+ default: "tools.apps.org.published",
320+ });
321+ expect(schema.body.inputSchema.required ?? []).not.toContain("apps");
322+
323+ const invoked = yield* Effect.promise(() =>
324+ execute(
325+ target,
326+ identity,
327+ `return await tools["apps.org.published.echo-tool"]({ message: "hello", apps: "tools.apps.org.published" });`,
328+ ),
329+ );
330+ expect(invoked.status, invoked.text).toBe("completed");
331+ expect(invoked.isError, invoked.text).toBe(false);
332+ expect(JSON.stringify(invoked.structured)).toContain("hello");
333+ expect(JSON.stringify(invoked.structured)).toContain("v1");
334+
335+ expect(git.packRequests(), "initial publish fetched one pack").toBe(1);
336+ git.advance();
337+ yield* syncSourceInConsole({
338+ target,
339+ browser,
340+ identity,
341+ expectedNotice: "Added: extra-tool",
342+ expectedToolCount: "3 tools",
343+ });
344+
345+ yield* syncSourceInConsole({
346+ target,
347+ browser,
348+ identity,
349+ expectedNotice: "Already up to date.",
350+ expectedToolCount: "3 tools",
351+ });
352+
353+ yield* removeSourceThroughConsole({ target, browser, identity });
354+
355+ const afterRemove = yield* Effect.promise(() =>
356+ request<readonly ToolRow[]>(target, identity, "/api/tools?integration=apps"),
357+ );
358+ expect(afterRemove.body).toEqual([]);
359+ }),
360+ Effect.promise(async () => {
361+ const sources = await request<{
362+ readonly sources: readonly { readonly slug: string; readonly app: string }[];
363+ }>(target, identity, "/api/apps/sources").catch(() => ({ body: { sources: [] } }));
364+ for (const source of sources.body.sources) {
365+ if (source.app === "repo") {
366+ await deletePath(
367+ target,
368+ identity,
369+ `/api/apps/sources/${encodeURIComponent(source.slug)}`,
370+ ).catch(() => undefined);
371+ }
372+ }
373+ }).pipe(Effect.ignore),
374+ );
375+ }),
376+ ),
377+);
packages/core/sdk/src/connections.test.ts+62−1
@@ -362,13 +362,14 @@ describe("connections.refresh", () => { · 361 unmodified lines
362362
363363 describe("tool catalog sync safety", () => {
364364 it.effect(
365− "background sync preserves a nonzero catalog when a plugin returns authoritative empty",
365+ "background sync preserves a nonzero remote catalog when a plugin returns authoritative empty",
366366 () =>
367367 Effect.scoped(
368368 Effect.gen(function* () {
369369 let empty = false;
370370 const guardedPlugin = definePlugin(() => ({
371371 id: "guarded" as const,
372+ remoteToolCatalog: true,
372373 credentialProviders: [memoryProvider()],
373374 storage: () => ({}),
374375 resolveTools: () =>
@@ -424,6 +425,66 @@ describe("tool catalog sync safety", () => { · 49 unmodified lines
424425 ),
425426 );
426427
428+ it.effect(
429+ "background sync clears a non-remote catalog when a plugin returns authoritative empty",
430+ () =>
431+ Effect.scoped(
432+ Effect.gen(function* () {
433+ let empty = false;
434+ const storedStatePlugin = definePlugin(() => ({
435+ id: "stored-state" as const,
436+ credentialProviders: [memoryProvider()],
437+ storage: () => ({}),
438+ resolveTools: () =>
439+ Effect.sync(() => ({
440+ tools: empty
441+ ? []
442+ : [
443+ { name: ToolName.make("deploy"), description: "deploy" },
444+ { name: ToolName.make("list"), description: "list" },
445+ ],
446+ })),
447+ invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),
448+ extension: (ctx) => ({
449+ seed: () =>
450+ ctx.core.integrations.register({
451+ slug: INTEG,
452+ description: "Vercel",
453+ config: {},
454+ }),
455+ }),
456+ }))();
457+ const config = makeTestConfig({ plugins: [storedStatePlugin] as const });
458+ const executor = yield* createExecutor(config);
459+ yield* executor["stored-state"].seed();
460+ yield* executor.connections.create({
461+ owner: "org",
462+ name: ConnectionName.make("main"),
463+ integration: INTEG,
464+ template: TEMPLATE,
465+ value: "secret-token",
466+ });
467+
468+ empty = true;
469+ yield* Effect.promise(() =>
470+ config.db.updateMany("connection", {
471+ where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")),
472+ set: { tools_synced_at: null },
473+ }),
474+ );
475+ const tools = yield* executor.tools.list({ integration: INTEG });
476+ const connection = yield* executor.connections.get({
477+ owner: "org",
478+ integration: INTEG,
479+ name: ConnectionName.make("main"),
480+ });
481+
482+ expect(tools).toEqual([]);
483+ expect(connection?.lastHealth).toBeNull();
484+ }),
485+ ),
486+ );
487+
427488 it.effect("explicit refresh accepts an authoritative empty catalog", () =>
428489 Effect.scoped(
429490 Effect.gen(function* () {
packages/core/sdk/src/executor.ts+5−1
@@ -2167,7 +2167,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2166 unmodified lines
21672167 return keptRows.map((row) => rowToTool(row as ConnectionToolRow));
21682168 }
21692169
2170− if (mode === "background" && result.tools.length === 0) {
2170+ if (
2171+ mode === "background" &&
2172+ runtime.plugin.remoteToolCatalog === true &&
2173+ result.tools.length === 0
2174+ ) {
21712175 const keptRows = yield* core.findMany("tool", { where });
21722176 if (keptRows.length > 0) {
21732177 const reason =
packages/plugins/apps/package.json+25−1
@@ -20,6 +20,8 @@ · 19 unmodified lines
2020 "./api": "./src/api.ts",
2121 "./authoring": "./src/authoring.ts",
2222 "./cloud": "./src/cloud.ts",
23+ "./client": "./src/react/plugin-client.tsx",
24+ "./react": "./src/react/index.ts",
2325 "./selfhost": "./src/selfhost.ts",
2426 "./testing": "./src/testing/index.ts"
2527 },
@@ -39,14 +41,36 @@ · 13 unmodified lines
3941 "zod": "4.3.6"
4042 },
4143 "devDependencies": {
44+ "@effect/atom-react": "catalog:",
4245 "@effect/vitest": "catalog:",
46+ "@executor-js/react": "workspace:*",
4347 "@types/node": "catalog:",
48+ "@types/react": "catalog:",
4449 "bun-types": "catalog:",
4550 "effect": "catalog:",
51+ "react": "catalog:",
4652 "tsup": "catalog:",
4753 "vitest": "catalog:"
4854 },
4955 "peerDependencies": {
50− "effect": "catalog:"
56+ "@effect/atom-react": "catalog:",
57+ "@executor-js/react": "workspace:*",
58+ "@tanstack/react-router": "catalog:",
59+ "effect": "catalog:",
60+ "react": "catalog:"
61+ },
62+ "peerDependenciesMeta": {
63+ "@effect/atom-react": {
64+ "optional": true
65+ },
66+ "@executor-js/react": {
67+ "optional": true
68+ },
69+ "@tanstack/react-router": {
70+ "optional": true
71+ },
72+ "react": {
73+ "optional": true
74+ }
5175 }
5276 }
packages/plugins/apps/src/git-client/url-security.ts+8−2
@@ -136,12 +136,18 @@ export const isPrivateGitHost = (host: string): boolean => { · 135 unmodified lines
136136
137137 export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => {
138138 const url = new URL(input);
139− if (url.protocol !== "https:") throw new Error("git source URL must use https");
139+ const privateHost = isPrivateGitHost(url.hostname);
140+ if (
141+ url.protocol !== "https:" &&
142+ !(url.protocol === "http:" && policy.allowPrivateHosts === true && privateHost)
143+ ) {
144+ throw new Error("git source URL must use https");
145+ }
140146 if (url.username || url.password)
141147 throw new Error("git source URL must not include embedded credentials");
142148 if (hasTokenLikeQuery(url))
143149 throw new Error("git source URL must not include token query parameters");
144− if (policy.allowPrivateHosts !== true && isPrivateGitHost(url.hostname)) {
150+ if (policy.allowPrivateHosts !== true && privateHost) {
145151 throw new Error("git source URL host is not allowed");
146152 }
147153 return url;
packages/plugins/apps/src/pipeline/publish.test.ts+12−1
@@ -58,7 +58,18 @@ const makeMemoryStore = (): AppsStore & { · 57 unmodified lines
5858 for (const key of rows.keys()) rows.delete(key);
5959 for (const item of next.tools) rows.set(item.name, item);
6060 }),
61− listActiveTools: () => Effect.sync(() => descriptor?.tools ?? []),
61+ removePublished: (app) =>
62+ Effect.sync(() => {
63+ if (descriptor?.app === app) {
64+ descriptor = null;
65+ descriptorKey = null;
66+ }
67+ for (const key of rows.keys()) rows.delete(key);
68+ }),
69+ listActiveTools: () =>
70+ Effect.sync(
71+ () => descriptor?.tools.map((tool) => ({ ...tool, app: descriptor?.app ?? "" })) ?? [],
72+ ),
6273 getTool: (name) =>
6374 Effect.sync(() => {
6475 const row = descriptor?.tools.find((item) => item.name === name);
packages/plugins/apps/src/plugin/apps-plugin.test.ts+19−3
@@ -74,7 +74,17 @@ const makeSyncStore = (): AppsStore & { · 73 unmodified lines
7474 descriptor = next;
7575 descriptorKey = nextDescriptorKey;
7676 }),
77− listActiveTools: () => Effect.sync(() => descriptor?.tools ?? []),
77+ removePublished: (app) =>
78+ Effect.sync(() => {
79+ if (descriptor?.app === app) {
80+ descriptor = null;
81+ descriptorKey = null;
82+ }
83+ }),
84+ listActiveTools: () =>
85+ Effect.sync(
86+ () => descriptor?.tools.map((tool) => ({ ...tool, app: descriptor?.app ?? "" })) ?? [],
87+ ),
7888 getTool: (name) =>
7989 Effect.sync(() => {
8090 const tool = descriptor?.tools.find((item) => item.name === name);
@@ -145,6 +155,7 @@ const makeInvokeStore = (input: { · 64 unmodified lines
145155 getBlob: () => Effect.succeed(input.bundle),
146156 getDescriptorRecord: () => Effect.succeed(null),
147157 putPublished: () => Effect.void,
158+ removePublished: () => Effect.void,
148159 listActiveTools: () => Effect.succeed([]),
149160 getTool: () =>
150161 Effect.succeed({
@@ -190,6 +201,7 @@ describe("apps plugin schema projection", () => { · 39 unmodified lines
190201 getBlob: () => Effect.succeed(null),
191202 getDescriptorRecord: () => Effect.succeed(null),
192203 putPublished: () => Effect.void,
204+ removePublished: () => Effect.void,
193205 listActiveTools: () => Effect.succeed([]),
194206 putSource: () => Effect.void,
195207 listSources: () => Effect.succeed([]),
@@ -233,14 +245,18 @@ describe("apps plugin schema projection", () => { · 37 unmodified lines
233245 );
234246 expect(result.inputSchema).toMatchObject({
235247 properties: {
236− crm: { enum: ["tools.dealcloud.org.main"] },
248+ crm: {
249+ enum: ["tools.dealcloud.org.main"],
250+ default: "tools.dealcloud.org.main",
251+ },
237252 inboxes: {
253+ default: ["tools.gmail.org.work", "tools.gmail.user.personal"],
238254 items: {
239255 enum: ["tools.gmail.org.work", "tools.gmail.user.personal"],
240256 },
241257 },
242258 },
243− required: ["crm", "inboxes"],
259+ required: undefined,
244260 });
245261 }),
246262 );
packages/plugins/apps/src/plugin/apps-plugin.ts+32−10
@@ -207,13 +207,7 @@ const activeToolNamesFor = ( · 206 unmodified lines
207207 ): Effect.Effect<readonly string[], unknown> =>
208208 ctx.storage
209209 .listActiveTools()
210− .pipe(
211− Effect.map((tools) =>
212− tools
213− .filter((tool) => tool.name.startsWith(`${app}__`) || tool.name === app)
214− .map((tool) => tool.name),
215− ),
216− );
210+ .pipe(Effect.map((tools) => tools.filter((tool) => tool.app === app).map((tool) => tool.name)));
217211
218212 const makeAppsExtension = (
219213 ctx: PluginCtx<AppsStore>,
@@ -281,6 +275,14 @@ const makeAppsExtension = ( · 61 unmodified lines
281275 deleteSource: (slug: string) =>
282276 Effect.gen(function* () {
283277 const record = yield* ctx.storage.getSource(slug);
278+ if (record) {
279+ yield* ctx.storage.removePublished(record.app, "org");
280+ yield* ctx.connections.markToolsStale({
281+ owner: "org",
282+ integration: APPS_INTEGRATION,
283+ name: APPS_CONNECTION,
284+ });
285+ }
284286 if (
285287 record?.config.kind === "git" &&
286288 record.config.tokenProvider &&
@@ -435,6 +437,7 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 148 unmodified lines
435437 definePlugin(() => ({
436438 id: "apps",
437439 packageName: "@executor-js/plugin-apps",
440+ clientConfig: { sourceKinds: options?.sourceKinds ?? ["git", "local-directory"] },
438441 pluginStorage: {
439442 [descriptorCollection.name]: descriptorCollection,
440443 [toolCollection.name]: toolCollection,
@@ -546,10 +549,29 @@ export const projectAppsToolSchema = ( · 105 unmodified lines
546549 const enumValues = connections.map((connection) => String(connection.address));
547550 properties[field] =
548551 decl.mode === "many"
549− ? { type: "array", items: { type: "string", enum: enumValues } }
550− : { type: "string", enum: enumValues };
552+ ? {
553+ type: "array",
554+ items: { type: "string", enum: enumValues },
555+ default: enumValues,
556+ }
557+ : {
558+ type: "string",
559+ enum: enumValues,
560+ ...(enumValues[0] ? { default: enumValues[0] } : {}),
561+ };
551562 }
552− return { inputSchema: { ...schema, properties }, outputSchema };
563+ const projectedFields = new Set(Object.keys(tool.integrations));
564+ const required = Array.isArray(schema.required)
565+ ? schema.required.filter((field) => !projectedFields.has(String(field)))
566+ : undefined;
567+ return {
568+ inputSchema: {
569+ ...schema,
570+ properties,
571+ ...(required && required.length > 0 ? { required } : { required: undefined }),
572+ },
573+ outputSchema,
574+ };
553575 });
554576
555577 export const appsPlugin = makeAppsPlugin();
packages/plugins/apps/src/plugin/store.test.ts+21−0
@@ -177,6 +177,27 @@ describe("apps store", () => { · 176 unmodified lines
177177 yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1");
178178 const active = yield* store.listActiveTools();
179179 expect(active.map((item) => item.name)).toEqual(["first"]);
180+ expect(active.map((item) => item.app)).toEqual(["crm"]);
181+ }),
182+ );
183+
184+ it.effect("removes a published app catalog", () =>
185+ Effect.gen(function* () {
186+ const store = makeAppsStore({
187+ blobs: pluginBlobStore(makeInMemoryBlobStore(), { org: "tenant", user: null }, "apps"),
188+ pluginStorage: makeMemoryPluginStorage(),
189+ });
190+ yield* store.putPublished(
191+ descriptor([tool("first"), tool("second")]),
192+ "descriptor",
193+ "org",
194+ null,
195+ );
196+ yield* store.removePublished("crm", "org");
197+ const active = yield* store.listActiveTools();
198+ const descriptorRecord = yield* store.getDescriptorRecord("crm");
199+ expect(active).toEqual([]);
200+ expect(descriptorRecord).toBeNull();
180201 }),
181202 );
182203
packages/plugins/apps/src/plugin/store.ts+19−1
@@ -102,8 +102,9 @@ export interface AppsStore { · 101 unmodified lines
102102 owner: Owner,
103103 expectedSourceRef: string | null,
104104 ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>;
105+ readonly removePublished: (app: string, owner: Owner) => Effect.Effect<void, StorageFailure>;
105106 readonly listActiveTools: () => Effect.Effect<
106− readonly AppDescriptor["tools"][number][],
107+ readonly (AppDescriptor["tools"][number] & { readonly app: string })[],
107108 StorageFailure
108109 >;
109110 readonly getTool: (name: string) => Effect.Effect<
@@ -215,10 +216,27 @@ export const makeAppsStore = (input: { · 105 unmodified lines
215216 },
216217 });
217218 }),
219+ removePublished: (app, owner) =>
220+ Effect.gen(function* () {
221+ const existing = yield* tools.query({ where: { app } });
222+ const now = Date.now();
223+ const entries: PutManyEntry[] = existing
224+ .filter((entry) => !entry.data.tombstoned)
225+ .map((entry) => ({
226+ collection: toolCollection.name,
227+ key: entry.key,
228+ data: { ...entry.data, tombstoned: true, updatedAt: now },
229+ }));
230+ if (entries.length > 0) {
231+ yield* input.pluginStorage.putMany({ owner, entries });
232+ }
233+ yield* descriptors.remove({ owner, key: app });
234+ }),
218235 listActiveTools: () =>
219236 tools.query({ where: { tombstoned: false } }).pipe(
220237 Effect.map((entries) =>
221238 entries.map((entry) => ({
239+ app: entry.data.app,
222240 name: entry.data.name,
223241 sourcePath: "",
224242 source: { path: "", sourceHash: "" },
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+264−0
@@ -0,0 +1,264 @@
1+import { useMemo, useState } from "react";
2+import { Effect, Exit } from "effect";
3+
4+import { Button } from "@executor-js/react/components/button";
5+import {
6+ CardStack,
7+ CardStackContent,
8+ CardStackEntryField,
9+} from "@executor-js/react/components/card-stack";
10+import { FloatActions } from "@executor-js/react/components/float-actions";
11+import { Input } from "@executor-js/react/components/input";
12+import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add";
13+
14+import {
15+ APPS_INTEGRATION_SLUG,
16+ createCustomToolSourceEffect,
17+ formatSyncErrors,
18+ parseGitSourceUrl,
19+ slugifyCustomToolsAppName,
20+ suggestCustomToolsAppName,
21+ syncCustomToolSourceEffect,
22+ validateCustomToolsAppSlug,
23+ validateGitSourceUrl,
24+ type AppSourceKind,
25+} from "./custom-tools-client";
26+
27+export default function AddCustomToolsSource(props: {
28+ readonly onComplete: (slug?: string) => void;
29+ readonly onCancel: () => void;
30+ readonly initialUrl?: string;
31+ readonly initialNamespace?: string;
32+ readonly sourceKinds?: readonly AppSourceKind[];
33+}) {
34+ const sourceKinds = props.sourceKinds ?? ["git"];
35+ const allowLocalDirectory = sourceKinds.includes("local-directory");
36+ const initialKind =
37+ props.initialUrl && props.initialUrl.startsWith("/") && allowLocalDirectory
38+ ? "local-directory"
39+ : "git";
40+ const [kind, setKind] = useState<AppSourceKind>(initialKind);
41+ const [url, setUrl] = useState(props.initialUrl ?? "");
42+ const [path, setPath] = useState(
43+ initialKind === "local-directory" ? (props.initialUrl ?? "") : "",
44+ );
45+ const [ref, setRef] = useState("");
46+ const [name, setName] = useState(
47+ props.initialNamespace ? slugifyCustomToolsAppName(props.initialNamespace) : "",
48+ );
49+ const [nameTouched, setNameTouched] = useState(props.initialNamespace !== undefined);
50+ const [token, setToken] = useState("");
51+ const [fieldError, setFieldError] = useState<string | null>(null);
52+ const [nameError, setNameError] = useState<string | null>(null);
53+ const [syncing, setSyncing] = useState(false);
54+ const [syncError, setSyncError] = useState<string | null>(null);
55+
56+ const sourceValue = kind === "git" ? url : path;
57+ const effectiveName = useMemo(
58+ () => (nameTouched ? name : slugifyCustomToolsAppName(suggestCustomToolsAppName(sourceValue))),
59+ [name, nameTouched, sourceValue],
60+ );
61+ const slug = effectiveName;
62+ const slugAlreadyExists = useSlugAlreadyExists(slug);
63+
64+ const submit = async () => {
65+ const nextFieldError =
66+ kind === "git"
67+ ? validateGitSourceUrl(url)
68+ : path.trim().startsWith("/")
69+ ? null
70+ : "Enter an absolute directory path.";
71+ const nextNameError = validateCustomToolsAppSlug(slug);
72+ setFieldError(nextFieldError);
73+ setNameError(nextNameError);
74+ setSyncError(null);
75+ if (nextFieldError || nextNameError) return;
76+ if (slugAlreadyExists) {
77+ setSyncError(`An integration named "${slug}" already exists. Choose another source name.`);
78+ return;
79+ }
80+
81+ setSyncing(true);
82+ const parsedGitUrl = kind === "git" ? parseGitSourceUrl(url) : null;
83+ const createExit = await Effect.runPromiseExit(
84+ createCustomToolSourceEffect(
85+ kind === "git"
86+ ? {
87+ kind: "git",
88+ slug,
89+ app: slug,
90+ url: parsedGitUrl?.ok ? parsedGitUrl.url : url.trim(),
91+ ...(ref.trim() ? { ref: ref.trim() } : {}),
92+ ...(token.trim() ? { token: token.trim() } : {}),
93+ }
94+ : { kind: "local-directory", slug, app: slug, path: path.trim() },
95+ ),
96+ );
97+ if (Exit.isFailure(createExit)) {
98+ setSyncError("Failed to create custom tools source.");
99+ setSyncing(false);
100+ return;
101+ }
102+ const syncExit = await Effect.runPromiseExit(
103+ syncCustomToolSourceEffect(createExit.value.source.slug),
104+ );
105+ if (Exit.isFailure(syncExit)) {
106+ setSyncError("Failed to sync custom tools source.");
107+ setSyncing(false);
108+ return;
109+ }
110+ const result = syncExit.value;
111+ if (result.status === "failed") {
112+ setSyncError(formatSyncErrors(result).join("\n") || "Sync failed.");
113+ setSyncing(false);
114+ return;
115+ }
116+ setSyncing(false);
117+ props.onComplete(APPS_INTEGRATION_SLUG);
118+ };
119+
120+ return (
121+ <div className="flex flex-1 flex-col gap-6">
122+ <div>
123+ <h1 className="text-xl font-semibold text-foreground">Add custom tools</h1>
124+ <p className="mt-1 text-[13px] text-muted-foreground">
125+ Sync tools from a Git repository, then publish them to the Apps catalog.
126+ </p>
127+ </div>
128+
129+ <CardStack>
130+ <CardStackContent className="border-t-0">
131+ {allowLocalDirectory && (
132+ <CardStackEntryField
133+ label="Source type"
134+ description="- Git is portable. Local directories are self-host only."
135+ >
136+ <div className="flex gap-2">
137+ <Button
138+ type="button"
139+ variant={kind === "git" ? "default" : "outline"}
140+ onClick={() => setKind("git")}
141+ disabled={syncing}
142+ >
143+ Git repository
144+ </Button>
145+ <Button
146+ type="button"
147+ variant={kind === "local-directory" ? "default" : "outline"}
148+ onClick={() => setKind("local-directory")}
149+ disabled={syncing}
150+ >
151+ Directory path
152+ </Button>
153+ </div>
154+ </CardStackEntryField>
155+ )}
156+
157+ {kind === "git" ? (
158+ <>
159+ <CardStackEntryField label="Git repository" description="- Any https Git remote.">
160+ <div className="space-y-1.5">
161+ <Input
162+ value={url}
163+ onChange={(event) => {
164+ setUrl((event.target as HTMLInputElement).value);
165+ setFieldError(null);
166+ setSyncError(null);
167+ }}
168+ onBlur={() => setFieldError(validateGitSourceUrl(url))}
169+ placeholder="https://github.com/acme/tools.git"
170+ className="font-mono text-sm"
171+ aria-invalid={fieldError ? true : undefined}
172+ />
173+ {fieldError && <p className="text-xs text-destructive">{fieldError}</p>}
174+ </div>
175+ </CardStackEntryField>
176+ <CardStackEntryField
177+ label="Ref (optional)"
178+ description="- Branch, tag, or commit SHA."
179+ >
180+ <Input
181+ value={ref}
182+ onChange={(event) => setRef((event.target as HTMLInputElement).value)}
183+ placeholder="main"
184+ className="font-mono text-sm"
185+ disabled={syncing}
186+ />
187+ </CardStackEntryField>
188+ <CardStackEntryField
189+ label="Token (optional)"
190+ description="- For private repositories. Never shown after saving."
191+ >
192+ <Input
193+ value={token}
194+ type="password"
195+ onChange={(event) => setToken((event.target as HTMLInputElement).value)}
196+ autoComplete="off"
197+ className="font-mono text-sm"
198+ disabled={syncing}
199+ />
200+ </CardStackEntryField>
201+ </>
202+ ) : (
203+ <CardStackEntryField
204+ label="Directory path"
205+ description="- Absolute path on the self-host server."
206+ >
207+ <div className="space-y-1.5">
208+ <Input
209+ value={path}
210+ onChange={(event) => {
211+ setPath((event.target as HTMLInputElement).value);
212+ setFieldError(null);
213+ setSyncError(null);
214+ }}
215+ placeholder="/srv/executor-tools"
216+ className="font-mono text-sm"
217+ aria-invalid={fieldError ? true : undefined}
218+ />
219+ {fieldError && <p className="text-xs text-destructive">{fieldError}</p>}
220+ </div>
221+ </CardStackEntryField>
222+ )}
223+
224+ <CardStackEntryField
225+ label="Source name"
226+ description="- Lowercase letters, numbers, and hyphens."
227+ >
228+ <div className="space-y-1.5">
229+ <Input
230+ value={effectiveName}
231+ onChange={(event) => {
232+ setNameTouched(true);
233+ setName(slugifyCustomToolsAppName((event.target as HTMLInputElement).value));
234+ setNameError(null);
235+ setSyncError(null);
236+ }}
237+ placeholder="custom-tools"
238+ className="text-sm"
239+ aria-invalid={nameError ? true : undefined}
240+ />
241+ {nameError && <p className="text-xs text-destructive">{nameError}</p>}
242+ {slugAlreadyExists && !syncing && !nameError && (
243+ <p className="text-xs text-destructive">
244+ An integration named &quot;{slug}&quot; already exists.
245+ </p>
246+ )}
247+ </div>
248+ </CardStackEntryField>
249+ </CardStackContent>
250+ </CardStack>
251+
252+ {syncError && <FormErrorAlert message={syncError} />}
253+
254+ <FloatActions>
255+ <Button type="button" variant="ghost" onClick={() => props.onCancel()} disabled={syncing}>
256+ Cancel
257+ </Button>
258+ <Button type="button" onClick={() => void submit()} disabled={syncing} loading={syncing}>
259+ Sync source
260+ </Button>
261+ </FloatActions>
262+ </div>
263+ );
264+}
packages/plugins/apps/src/react/CustomToolsAccountsPanel.tsx+287−0
@@ -0,0 +1,287 @@
1+import { useEffect, useState } from "react";
2+import { Effect, Exit } from "effect";
3+
4+import {
5+ AlertDialog,
6+ AlertDialogAction,
7+ AlertDialogCancel,
8+ AlertDialogContent,
9+ AlertDialogDescription,
10+ AlertDialogFooter,
11+ AlertDialogHeader,
12+ AlertDialogTitle,
13+ AlertDialogTrigger,
14+} from "@executor-js/react/components/alert-dialog";
15+import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
16+import { Button } from "@executor-js/react/components/button";
17+import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
18+
19+import {
20+ listCustomToolSourcesEffect,
21+ removeCustomToolSourceEffect,
22+ syncCustomToolSourceEffect,
23+ type AppSourceRecord,
24+} from "./custom-tools-client";
25+import {
26+ sourceFailureLines,
27+ sourcePanelModel,
28+ syncNoticeFromResult,
29+ toolsCountLabel,
30+ type SyncNoticeModel,
31+} from "./source-panel-model";
32+
33+const integrationsHref = (): string => {
34+ const match = window.location.pathname.match(/^(.*)\/integrations(?:\/.*)?$/);
35+ return `${match?.[1] ?? ""}/integrations`;
36+};
37+
38+type LoadState =
39+ | { readonly status: "loading" }
40+ | { readonly status: "error"; readonly message: string }
41+ | { readonly status: "ready"; readonly sources: readonly AppSourceRecord[] };
42+
43+export default function CustomToolsAccountsPanel() {
44+ const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });
45+ const [syncingSlug, setSyncingSlug] = useState<string | null>(null);
46+ const [removingSlug, setRemovingSlug] = useState<string | null>(null);
47+ const [notice, setNotice] = useState<Record<string, SyncNoticeModel | undefined>>({});
48+ const [removeError, setRemoveError] = useState<string | null>(null);
49+
50+ const loadSources = async () => {
51+ setLoadState({ status: "loading" });
52+ const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());
53+ if (Exit.isFailure(exit)) {
54+ setLoadState({ status: "error", message: "Failed to load custom tools sources." });
55+ return;
56+ }
57+ setLoadState({ status: "ready", sources: exit.value.sources });
58+ };
59+
60+ useEffect(() => {
61+ let active = true;
62+ void (async () => {
63+ const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect());
64+ if (!active) return;
65+ if (Exit.isFailure(exit)) {
66+ setLoadState({ status: "error", message: "Failed to load custom tools sources." });
67+ return;
68+ }
69+ setLoadState({ status: "ready", sources: exit.value.sources });
70+ })();
71+ return () => {
72+ active = false;
73+ };
74+ }, []);
75+
76+ const syncSource = async (source: AppSourceRecord) => {
77+ setSyncingSlug(source.slug);
78+ setNotice((current) => ({ ...current, [source.slug]: undefined }));
79+ const beforeTools =
80+ source.status.type === "published" || source.status.type === "up-to-date"
81+ ? source.status.tools
82+ : [];
83+ const exit = await Effect.runPromiseExit(syncCustomToolSourceEffect(source.slug));
84+ if (Exit.isFailure(exit)) {
85+ setNotice((current) => ({
86+ ...current,
87+ [source.slug]: {
88+ status: "failed",
89+ message: "Sync failed.",
90+ added: [],
91+ removed: [],
92+ errors: ["Failed to sync custom tools source."],
93+ },
94+ }));
95+ setSyncingSlug(null);
96+ return;
97+ }
98+ setNotice((current) => ({
99+ ...current,
100+ [source.slug]: syncNoticeFromResult(exit.value, beforeTools),
101+ }));
102+ await loadSources();
103+ setSyncingSlug(null);
104+ };
105+
106+ const removeSource = async (source: AppSourceRecord) => {
107+ setRemovingSlug(source.slug);
108+ setRemoveError(null);
109+ const exit = await Effect.runPromiseExit(removeCustomToolSourceEffect(source.slug));
110+ if (Exit.isFailure(exit)) {
111+ setRemoveError("Failed to remove custom tools source.");
112+ setRemovingSlug(null);
113+ return;
114+ }
115+ await loadSources();
116+ setRemovingSlug(null);
117+ window.location.assign(integrationsHref());
118+ };
119+
120+ return (
121+ <div className="mx-auto max-w-3xl space-y-6 px-6 py-8">
122+ {loadState.status === "loading" && (
123+ <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground">
124+ Loading sources...
125+ </div>
126+ )}
127+
128+ {loadState.status === "error" && (
129+ <Alert variant="destructive">
130+ <AlertTitle>Failed to load sources</AlertTitle>
131+ <AlertDescription>
132+ <div className="space-y-3">
133+ <p>{loadState.message}</p>
134+ <Button type="button" size="sm" variant="outline" onClick={() => void loadSources()}>
135+ Retry
136+ </Button>
137+ </div>
138+ </AlertDescription>
139+ </Alert>
140+ )}
141+
142+ {loadState.status === "ready" && loadState.sources.length === 0 && (
143+ <div className="rounded-lg border border-border p-4">
144+ <p className="text-sm font-medium text-foreground">No custom tool sources</p>
145+ <p className="mt-1 text-sm text-muted-foreground">
146+ Add a Git repository to publish tools into the Apps catalog.
147+ </p>
148+ </div>
149+ )}
150+
151+ {loadState.status === "ready" &&
152+ loadState.sources.map((source) => (
153+ <SourceDetail
154+ key={source.slug}
155+ source={source}
156+ notice={notice[source.slug] ?? null}
157+ removeError={removeError}
158+ syncing={syncingSlug === source.slug}
159+ removing={removingSlug === source.slug}
160+ onSync={() => void syncSource(source)}
161+ onRemove={() => void removeSource(source)}
162+ />
163+ ))}
164+ </div>
165+ );
166+}
167+
168+function SourceDetail(props: {
169+ readonly source: AppSourceRecord;
170+ readonly notice: SyncNoticeModel | null;
171+ readonly removeError: string | null;
172+ readonly syncing: boolean;
173+ readonly removing: boolean;
174+ readonly onSync: () => void;
175+ readonly onRemove: () => void;
176+}) {
177+ const { source, notice } = props;
178+ const model = sourcePanelModel(source);
179+ const failureLines = sourceFailureLines(source);
180+ const noticeHasDetails =
181+ notice !== null &&
182+ (notice.added.length > 0 ||
183+ notice.removed.length > 0 ||
184+ notice.errors.length > 0 ||
185+ notice.sourceRef !== undefined);
186+ return (
187+ <div className="min-w-0 rounded-lg border border-border bg-card">
188+ <div className="flex items-start justify-between gap-4 border-b border-border p-4">
189+ <div className="min-w-0">
190+ <h3 className="truncate text-sm font-medium text-foreground">{model.title}</h3>
191+ <p className="mt-1 truncate text-xs text-muted-foreground">{model.source}</p>
192+ </div>
193+ <div className="flex shrink-0 gap-2">
194+ <Button type="button" size="sm" onClick={props.onSync} loading={props.syncing}>
195+ Sync
196+ </Button>
197+ <AlertDialog>
198+ <AlertDialogTrigger asChild>
199+ <Button
200+ type="button"
201+ size="sm"
202+ variant="outline"
203+ className="border-destructive/30 text-destructive hover:bg-destructive/10"
204+ disabled={props.removing}
205+ >
206+ Remove
207+ </Button>
208+ </AlertDialogTrigger>
209+ <AlertDialogContent size="sm">
210+ <AlertDialogHeader>
211+ <AlertDialogTitle>Remove {source.app}?</AlertDialogTitle>
212+ <AlertDialogDescription>
213+ This removes {toolsCountLabel(model.tools.length)} from the catalog. The source is
214+ untouched and can be added again later.
215+ </AlertDialogDescription>
216+ </AlertDialogHeader>
217+ <AlertDialogFooter>
218+ <AlertDialogCancel>Cancel</AlertDialogCancel>
219+ <AlertDialogAction variant="destructive" onClick={props.onRemove}>
220+ {props.removing ? "Removing..." : "Remove source"}
221+ </AlertDialogAction>
222+ </AlertDialogFooter>
223+ </AlertDialogContent>
224+ </AlertDialog>
225+ </div>
226+ </div>
227+
228+ <div className="space-y-5 p-4">
229+ <div className="grid gap-3 text-sm sm:grid-cols-3">
230+ <Meta label="Status" value={model.status} />
231+ <Meta label="Source ref" value={model.sourceRef} mono />
232+ <Meta label="Tools" value={toolsCountLabel(model.tools.length)} />
233+ </div>
234+
235+ {failureLines.length > 0 && (
236+ <Alert variant="destructive">
237+ <AlertTitle>Last sync failed</AlertTitle>
238+ <AlertDescription>
239+ <div className="space-y-1">
240+ {failureLines.map((line) => (
241+ <p key={line}>{line}</p>
242+ ))}
243+ </div>
244+ </AlertDescription>
245+ </Alert>
246+ )}
247+
248+ {notice && (
249+ <Alert variant={notice.status === "failed" ? "destructive" : "default"}>
250+ <AlertTitle>{notice.message}</AlertTitle>
251+ {noticeHasDetails && (
252+ <AlertDescription>
253+ <div className="space-y-1">
254+ {notice.added.length > 0 && <p>Added: {notice.added.join(", ")}</p>}
255+ {notice.removed.length > 0 && <p>Removed: {notice.removed.join(", ")}</p>}
256+ {notice.errors.map((error) => (
257+ <p key={error}>{error}</p>
258+ ))}
259+ {notice.sourceRef && (
260+ <p className="font-mono text-xs text-muted-foreground">
261+ Commit {notice.sourceRef}
262+ </p>
263+ )}
264+ </div>
265+ </AlertDescription>
266+ )}
267+ </Alert>
268+ )}
269+
270+ {props.removeError && <FormErrorAlert message={props.removeError} />}
271+ </div>
272+ </div>
273+ );
274+}
275+
276+function Meta(props: { readonly label: string; readonly value: string; readonly mono?: boolean }) {
277+ return (
278+ <div className="min-w-0">
279+ <p className="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
280+ {props.label}
281+ </p>
282+ <p className={props.mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate text-sm"}>
283+ {props.value}
284+ </p>
285+ </div>
286+ );
287+}
packages/plugins/apps/src/react/custom-tools-client.ts+311−0
@@ -0,0 +1,311 @@
1+import { Data, Effect, Schema } from "effect";
2+
3+export const CUSTOM_TOOLS_PLUGIN_KEY = "apps";
4+export const CUSTOM_TOOLS_LABEL = "Custom tools";
5+export const APPS_INTEGRATION_SLUG = "apps";
6+
7+export type AppSourceKind = "git" | "local-directory";
8+
9+export interface SyncDiagnostic {
10+ readonly stage: "source" | "discover" | "bundle" | "collect" | "project";
11+ readonly message: string;
12+ readonly diagnostics?: readonly { readonly path: string; readonly message: string }[];
13+}
14+
15+export type SourceStatus =
16+ | { readonly type: "pending" }
17+ | {
18+ readonly type: "published" | "up-to-date";
19+ readonly at: number;
20+ readonly tools: readonly string[];
21+ }
22+ | { readonly type: "failed"; readonly at: number; readonly errors: readonly SyncDiagnostic[] };
23+
24+export type AppSourceRecord =
25+ | {
26+ readonly slug: string;
27+ readonly app: string;
28+ readonly kind: "git";
29+ readonly config: {
30+ readonly kind: "git";
31+ readonly url: string;
32+ readonly ref?: string;
33+ readonly tokenProvider?: string;
34+ readonly tokenItemId?: string;
35+ };
36+ readonly sourceRef?: string;
37+ readonly description?: string;
38+ readonly status: SourceStatus;
39+ readonly updatedAt: number;
40+ }
41+ | {
42+ readonly slug: string;
43+ readonly app: string;
44+ readonly kind: "local-directory";
45+ readonly config: { readonly kind: "local-directory"; readonly path: string };
46+ readonly sourceRef?: string;
47+ readonly description?: string;
48+ readonly status: SourceStatus;
49+ readonly updatedAt: number;
50+ };
51+
52+export interface SourcesListResponse {
53+ readonly sources: readonly AppSourceRecord[];
54+}
55+
56+export interface SourceDetailResponse {
57+ readonly source: AppSourceRecord | null;
58+}
59+
60+export type CreateSourceRequest =
61+ | {
62+ readonly kind: "git";
63+ readonly slug?: string;
64+ readonly app?: string;
65+ readonly url: string;
66+ readonly ref?: string;
67+ readonly token?: string;
68+ }
69+ | {
70+ readonly kind: "local-directory";
71+ readonly slug?: string;
72+ readonly app?: string;
73+ readonly path: string;
74+ };
75+
76+export interface CreateSourceResponse {
77+ readonly source: AppSourceRecord;
78+}
79+
80+export type SyncSourceResult =
81+ | {
82+ readonly status: "published" | "up-to-date";
83+ readonly sourceRef: string;
84+ readonly tools: readonly string[];
85+ readonly errors?: undefined;
86+ }
87+ | {
88+ readonly status: "failed";
89+ readonly sourceRef?: string;
90+ readonly tools: readonly string[];
91+ readonly errors: readonly SyncDiagnostic[];
92+ };
93+
94+export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
95+
96+export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{
97+ readonly message: string;
98+}> {}
99+
100+export const slugifyCustomToolsAppName = (value: string): string =>
101+ value
102+ .trim()
103+ .toLowerCase()
104+ .replace(/[^a-z0-9-]+/g, "-")
105+ .replace(/^-+|-+$/g, "")
106+ .slice(0, 80);
107+
108+export const validateCustomToolsAppSlug = (value: string): string | null => {
109+ if (!value.trim()) return "Enter a source name.";
110+ if (value !== slugifyCustomToolsAppName(value)) {
111+ return "Use lowercase letters, numbers, and hyphens.";
112+ }
113+ return null;
114+};
115+
116+export const parseGitSourceUrl = (
117+ raw: string,
118+):
119+ | { readonly ok: true; readonly url: string; readonly name: string }
120+ | { readonly ok: false; readonly message: string } => {
121+ const value = raw.trim();
122+ if (!value) return { ok: false, message: "Enter a Git repository URL." };
123+ if (!URL.canParse(value)) return { ok: false, message: "Enter an https Git repository URL." };
124+ const parsed = new URL(value);
125+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
126+ return { ok: false, message: "Git repository URLs must use http or https." };
127+ }
128+ if (parsed.username || parsed.password) {
129+ return { ok: false, message: "Do not include credentials in the URL. Use the token field." };
130+ }
131+ const segments = parsed.pathname.split("/").filter(Boolean);
132+ const last = segments.at(-1)?.replace(/\.git$/, "") ?? "";
133+ if (!last) return { ok: false, message: "Enter a repository URL with a path." };
134+ parsed.hash = "";
135+ return { ok: true, url: parsed.toString(), name: last };
136+};
137+
138+export const suggestCustomToolsAppName = (urlOrPath: string): string => {
139+ const parsed = parseGitSourceUrl(urlOrPath);
140+ if (parsed.ok) return parsed.name;
141+ const pathParts = urlOrPath.split("/").filter(Boolean);
142+ return pathParts.at(-1) ?? "";
143+};
144+
145+export const validateGitSourceUrl = (url: string): string | null => {
146+ const parsed = parseGitSourceUrl(url);
147+ return parsed.ok ? null : parsed.message;
148+};
149+
150+const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
151+const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
152+
153+const isRecord = (value: unknown): value is Record<string, unknown> =>
154+ value !== null && typeof value === "object" && !Array.isArray(value);
155+
156+const responseErrorMessage = (body: unknown, fallback: string): string =>
157+ isRecord(body) && typeof body.message === "string"
158+ ? body.message
159+ : isRecord(body) && typeof body.error === "string"
160+ ? body.error
161+ : fallback;
162+
163+const parseJsonResponseEffect = <A>(
164+ response: Response,
165+ fallback: string,
166+): Effect.Effect<A, CustomToolsClientError> =>
167+ Effect.gen(function* () {
168+ const text = yield* Effect.tryPromise({
169+ try: () => response.text(),
170+ catch: () => new CustomToolsClientError({ message: fallback }),
171+ });
172+ const body =
173+ text.length > 0
174+ ? yield* decodeJsonText(text).pipe(
175+ Effect.mapError(() => new CustomToolsClientError({ message: fallback })),
176+ )
177+ : null;
178+ if (!response.ok) {
179+ return yield* new CustomToolsClientError({
180+ message: responseErrorMessage(body, fallback),
181+ });
182+ }
183+ return body as A;
184+ });
185+
186+export const listCustomToolSourcesEffect = (
187+ fetchImpl: CustomToolsFetch = fetch,
188+): Effect.Effect<SourcesListResponse, CustomToolsClientError> =>
189+ Effect.tryPromise({
190+ try: () => fetchImpl("/api/apps/sources", { credentials: "same-origin" }),
191+ catch: () => new CustomToolsClientError({ message: "Failed to load custom tools sources." }),
192+ }).pipe(
193+ Effect.flatMap((response) =>
194+ parseJsonResponseEffect<SourcesListResponse>(
195+ response,
196+ "Failed to load custom tools sources.",
197+ ),
198+ ),
199+ );
200+
201+export const listCustomToolSources = (
202+ fetchImpl: CustomToolsFetch = fetch,
203+): Promise<SourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl));
204+
205+export const getCustomToolSourceEffect = (
206+ slug: string,
207+ fetchImpl: CustomToolsFetch = fetch,
208+): Effect.Effect<SourceDetailResponse, CustomToolsClientError> =>
209+ Effect.tryPromise({
210+ try: () =>
211+ fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, {
212+ credentials: "same-origin",
213+ }),
214+ catch: () => new CustomToolsClientError({ message: "Failed to load custom tools source." }),
215+ }).pipe(
216+ Effect.flatMap((response) =>
217+ parseJsonResponseEffect<SourceDetailResponse>(
218+ response,
219+ "Failed to load custom tools source.",
220+ ),
221+ ),
222+ );
223+
224+export const createCustomToolSourceEffect = (
225+ input: CreateSourceRequest,
226+ fetchImpl: CustomToolsFetch = fetch,
227+): Effect.Effect<CreateSourceResponse, CustomToolsClientError> =>
228+ Effect.tryPromise({
229+ try: () =>
230+ fetchImpl("/api/apps/sources", {
231+ method: "POST",
232+ headers: { "content-type": "application/json" },
233+ credentials: "same-origin",
234+ body: encodeJsonText(input),
235+ }),
236+ catch: () => new CustomToolsClientError({ message: "Failed to create custom tools source." }),
237+ }).pipe(
238+ Effect.flatMap((response) =>
239+ parseJsonResponseEffect<CreateSourceResponse>(
240+ response,
241+ "Failed to create custom tools source.",
242+ ),
243+ ),
244+ );
245+
246+export const syncCustomToolSourceEffect = (
247+ slug: string,
248+ fetchImpl: CustomToolsFetch = fetch,
249+): Effect.Effect<SyncSourceResult, CustomToolsClientError> =>
250+ Effect.tryPromise({
251+ try: () =>
252+ fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}/sync`, {
253+ method: "POST",
254+ credentials: "same-origin",
255+ }),
256+ catch: () => new CustomToolsClientError({ message: "Failed to sync custom tools source." }),
257+ }).pipe(
258+ Effect.flatMap((response) =>
259+ parseJsonResponseEffect<SyncSourceResult>(response, "Failed to sync custom tools source."),
260+ ),
261+ );
262+
263+export const removeCustomToolSourceEffect = (
264+ slug: string,
265+ fetchImpl: CustomToolsFetch = fetch,
266+): Effect.Effect<{ readonly removed: boolean }, CustomToolsClientError> =>
267+ Effect.tryPromise({
268+ try: () =>
269+ fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, {
270+ method: "DELETE",
271+ credentials: "same-origin",
272+ }),
273+ catch: () => new CustomToolsClientError({ message: "Failed to remove custom tools source." }),
274+ }).pipe(
275+ Effect.flatMap((response) =>
276+ parseJsonResponseEffect<{ readonly removed: boolean }>(
277+ response,
278+ "Failed to remove custom tools source.",
279+ ),
280+ ),
281+ );
282+
283+export const syncStatusLabel = (result: SyncSourceResult): string => {
284+ if (result.status === "published") return `Published ${result.tools.length} tools.`;
285+ if (result.status === "up-to-date") return "Already up to date.";
286+ return "Sync failed.";
287+};
288+
289+export const formatDiagnostics = (
290+ errors: readonly SyncDiagnostic[] | undefined,
291+): readonly string[] =>
292+ (errors ?? []).flatMap((entry) => {
293+ const head = `${entry.stage}: ${entry.message}`;
294+ const details = entry.diagnostics?.map((d) => `${d.path}: ${d.message}`) ?? [];
295+ return details.length > 0 ? [head, ...details] : [head];
296+ });
297+
298+export const formatSyncErrors = (result: SyncSourceResult): readonly string[] =>
299+ result.status === "failed" ? formatDiagnostics(result.errors) : [];
300+
301+export const toolDiff = (
302+ before: readonly string[],
303+ after: readonly string[],
304+): { readonly added: readonly string[]; readonly removed: readonly string[] } => {
305+ const beforeSet = new Set(before);
306+ const afterSet = new Set(after);
307+ return {
308+ added: after.filter((tool) => !beforeSet.has(tool)),
309+ removed: before.filter((tool) => !afterSet.has(tool)),
310+ };
311+};
packages/plugins/apps/src/react/custom-tools-client.web.test.ts+174−0
@@ -0,0 +1,174 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+
4+import appsClientPlugin, {
5+ APPS_INTEGRATION_SLUG,
6+ CUSTOM_TOOLS_LABEL,
7+ CUSTOM_TOOLS_PLUGIN_KEY,
8+ createCustomToolSourceEffect,
9+ formatSyncErrors,
10+ listCustomToolSources,
11+ parseGitSourceUrl,
12+ removeCustomToolSourceEffect,
13+ slugifyCustomToolsAppName,
14+ suggestCustomToolsAppName,
15+ syncCustomToolSourceEffect,
16+ syncStatusLabel,
17+ type AppSourceRecord,
18+ type CustomToolsFetch,
19+} from "./plugin-client";
20+import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model";
21+
22+const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
23+ new Response(JSON.stringify(body), {
24+ status: 200,
25+ headers: { "content-type": "application/json" },
26+ ...init,
27+ });
28+
29+const demoSource: AppSourceRecord = {
30+ slug: "demo-tools",
31+ app: "demo-tools",
32+ kind: "git",
33+ config: {
34+ kind: "git",
35+ url: "https://gitlab.com/acme/demo-tools.git",
36+ ref: "main",
37+ tokenProvider: "default",
38+ tokenItemId: "apps/source-tokens/demo-tools",
39+ },
40+ sourceRef: "abc123def456",
41+ description: "Demo tools",
42+ status: {
43+ type: "published",
44+ at: Date.parse("2026-07-06T12:00:00.000Z"),
45+ tools: ["repo-summary", "stale-issues"],
46+ },
47+ updatedAt: Date.parse("2026-07-06T12:00:00.000Z"),
48+};
49+
50+describe("custom tools console client", () => {
51+ it("registers the Apps integration plugin", () => {
52+ const plugin = appsClientPlugin({ sourceKinds: ["git"] });
53+
54+ expect(plugin.id).toBe(CUSTOM_TOOLS_PLUGIN_KEY);
55+ expect(plugin.integrationPlugin?.label).toBe(CUSTOM_TOOLS_LABEL);
56+ expect(APPS_INTEGRATION_SLUG).toBe("apps");
57+ });
58+
59+ it("validates and suggests generic https Git repository URLs", () => {
60+ expect(parseGitSourceUrl("https://github.com/UsefulSoftwareCo/executor.git")).toMatchObject({
61+ ok: true,
62+ name: "executor",
63+ url: "https://github.com/UsefulSoftwareCo/executor.git",
64+ });
65+ expect(parseGitSourceUrl("https://gitlab.com/acme/tools")).toMatchObject({
66+ ok: true,
67+ name: "tools",
68+ });
69+ expect(parseGitSourceUrl("ssh://gitlab.com/acme/tools")).toMatchObject({
70+ ok: false,
71+ message: "Git repository URLs must use http or https.",
72+ });
73+ expect(parseGitSourceUrl("https://x:secret@gitlab.com/acme/tools")).toMatchObject({
74+ ok: false,
75+ message: "Do not include credentials in the URL. Use the token field.",
76+ });
77+ expect(suggestCustomToolsAppName("https://gitlab.com/acme/tools.git")).toBe("tools");
78+ expect(slugifyCustomToolsAppName("Acme Tools!")).toBe("acme-tools");
79+ });
80+
81+ it("uses the new source routes and keeps token-bearing input out of read responses", async () => {
82+ const calls: Array<{ readonly url: string; readonly init?: RequestInit }> = [];
83+ let createBody = "";
84+ const fetchImpl: CustomToolsFetch = async (input, init) => {
85+ const url = String(input);
86+ calls.push({ url, init });
87+ if (url === "/api/apps/sources" && init?.method === "POST") {
88+ createBody = String(init.body);
89+ return jsonResponse({ source: demoSource });
90+ }
91+ if (url === "/api/apps/sources") {
92+ return jsonResponse({ sources: [demoSource] });
93+ }
94+ if (url === "/api/apps/sources/demo-tools/sync") {
95+ return jsonResponse({
96+ status: "up-to-date",
97+ sourceRef: "abc123def456",
98+ tools: ["repo-summary", "stale-issues"],
99+ });
100+ }
101+ if (url === "/api/apps/sources/demo-tools") {
102+ return jsonResponse({ removed: true });
103+ }
104+ return jsonResponse({ error: "not found" }, { status: 404 });
105+ };
106+
107+ const created = await Effect.runPromise(
108+ createCustomToolSourceEffect(
109+ {
110+ kind: "git",
111+ slug: "demo-tools",
112+ app: "demo-tools",
113+ url: "https://gitlab.com/acme/demo-tools.git",
114+ token: "ghp_secret",
115+ },
116+ fetchImpl,
117+ ),
118+ );
119+ const listed = await listCustomToolSources(fetchImpl);
120+ const synced = await Effect.runPromise(syncCustomToolSourceEffect("demo-tools", fetchImpl));
121+ const removed = await Effect.runPromise(removeCustomToolSourceEffect("demo-tools", fetchImpl));
122+
123+ expect(created.source.slug).toBe("demo-tools");
124+ expect(createBody).toContain("ghp_secret");
125+ expect(JSON.stringify(listed)).not.toContain("ghp_secret");
126+ expect(syncStatusLabel(synced)).toBe("Already up to date.");
127+ expect(removed).toEqual({ removed: true });
128+ expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([
129+ "POST /api/apps/sources",
130+ "GET /api/apps/sources",
131+ "POST /api/apps/sources/demo-tools/sync",
132+ "DELETE /api/apps/sources/demo-tools",
133+ ]);
134+ });
135+
136+ it("models the source panel and sync notice", () => {
137+ const panel = sourcePanelModel(demoSource, {
138+ now: Date.parse("2026-07-06T12:05:00.000Z"),
139+ });
140+ const notice = syncNoticeFromResult(
141+ {
142+ status: "published",
143+ sourceRef: "def456abc123",
144+ tools: ["repo-summary", "stale-issues", "extra-tool"],
145+ },
146+ demoSource.status.type === "published" ? demoSource.status.tools : [],
147+ );
148+
149+ expect(panel.title).toBe("demo-tools");
150+ expect(panel.source).toBe("Git repository: https://gitlab.com/acme/demo-tools.git @ main");
151+ expect(panel.status).toBe("Published 5m ago");
152+ expect(panel.sourceRef).toBe("abc123def456");
153+ expect(panel.tools).toEqual(["repo-summary", "stale-issues"]);
154+ expect(notice.message).toBe("Published 3 tools.");
155+ expect(notice.added).toEqual(["extra-tool"]);
156+ expect(notice.sourceRef).toBe("def456abc123");
157+ });
158+
159+ it("formats failed sync diagnostics by stage", () => {
160+ const errors = formatSyncErrors({
161+ status: "failed",
162+ tools: [],
163+ errors: [
164+ {
165+ stage: "collect",
166+ message: "schema export failed",
167+ diagnostics: [{ path: "tools/x.ts", message: "unsupported schema" }],
168+ },
169+ ],
170+ });
171+
172+ expect(errors).toEqual(["collect: schema export failed", "tools/x.ts: unsupported schema"]);
173+ });
174+});
packages/plugins/apps/src/react/index.ts+2−0
@@ -0,0 +1,2 @@
1+export { default } from "./plugin-client";
2+export * from "./custom-tools-client";
packages/plugins/apps/src/react/plugin-client.tsx+16−0
@@ -0,0 +1,16 @@
1+import { defineClientPlugin } from "@executor-js/sdk/client";
2+
3+import { makeAppsIntegrationPlugin } from "./source-plugin";
4+import type { AppSourceKind } from "./custom-tools-client";
5+
6+export { makeAppsIntegrationPlugin } from "./source-plugin";
7+export * from "./custom-tools-client";
8+
9+export default function appsClientPlugin(config?: {
10+ readonly sourceKinds?: readonly AppSourceKind[];
11+}) {
12+ return defineClientPlugin({
13+ id: "apps",
14+ integrationPlugin: makeAppsIntegrationPlugin(config),
15+ });
16+}
packages/plugins/apps/src/react/source-panel-model.ts+88−0
@@ -0,0 +1,88 @@
1+import type { AppSourceRecord, SyncSourceResult } from "./custom-tools-client";
2+import {
3+ formatDiagnostics,
4+ formatSyncErrors,
5+ syncStatusLabel,
6+ toolDiff,
7+} from "./custom-tools-client";
8+
9+export interface SourcePanelModel {
10+ readonly title: string;
11+ readonly source: string;
12+ readonly sourceRef: string;
13+ readonly status: string;
14+ readonly tools: readonly string[];
15+}
16+
17+export interface SyncNoticeModel {
18+ readonly status: SyncSourceResult["status"];
19+ readonly message: string;
20+ readonly added: readonly string[];
21+ readonly removed: readonly string[];
22+ readonly errors: readonly string[];
23+ readonly sourceRef?: string;
24+}
25+
26+const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced");
27+
28+export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => {
29+ const diffMs = Math.max(0, now - timestamp);
30+ const min = Math.floor(diffMs / 60_000);
31+ if (min < 1) return "just now";
32+ if (min < 60) return `${min}m ago`;
33+ const hrs = Math.floor(min / 60);
34+ if (hrs < 24) return `${hrs}h ago`;
35+ const days = Math.floor(hrs / 24);
36+ if (days < 7) return `${days}d ago`;
37+ return new Date(timestamp).toLocaleDateString();
38+};
39+
40+export const toolsCountLabel = (count: number): string =>
41+ `${count} ${count === 1 ? "tool" : "tools"}`;
42+
43+export const sourcePanelModel = (
44+ source: AppSourceRecord,
45+ options?: { readonly now?: number },
46+): SourcePanelModel => {
47+ const tools =
48+ source.status.type === "published" || source.status.type === "up-to-date"
49+ ? source.status.tools
50+ : [];
51+ const sourceLabel = source.config.kind === "git" ? source.config.url : source.config.path;
52+ const pin = source.config.kind === "git" && source.config.ref ? ` @ ${source.config.ref}` : "";
53+ const status =
54+ source.status.type === "pending"
55+ ? "Pending sync"
56+ : source.status.type === "failed"
57+ ? `Failed ${formatRelativeSyncTime(source.status.at, options?.now)}`
58+ : `${source.status.type === "published" ? "Published" : "Up to date"} ${formatRelativeSyncTime(
59+ source.status.at,
60+ options?.now,
61+ )}`;
62+ return {
63+ title: source.app,
64+ source: `${source.kind === "git" ? "Git repository" : "Local directory"}: ${sourceLabel}${pin}`,
65+ sourceRef: shortRef(source.sourceRef),
66+ status,
67+ tools,
68+ };
69+};
70+
71+export const sourceFailureLines = (source: AppSourceRecord): readonly string[] =>
72+ source.status.type === "failed" ? formatDiagnostics(source.status.errors) : [];
73+
74+export const syncNoticeFromResult = (
75+ result: SyncSourceResult,
76+ beforeTools: readonly string[],
77+): SyncNoticeModel => {
78+ const diff =
79+ result.status === "failed" ? { added: [], removed: [] } : toolDiff(beforeTools, result.tools);
80+ return {
81+ status: result.status,
82+ message: syncStatusLabel(result),
83+ added: diff.added,
84+ removed: diff.removed,
85+ errors: formatSyncErrors(result),
86+ ...(result.sourceRef ? { sourceRef: result.sourceRef.slice(0, 12) } : {}),
87+ };
88+};
packages/plugins/apps/src/react/source-plugin.tsx+36−0
@@ -0,0 +1,36 @@
1+import { lazy } from "react";
2+import type { IntegrationPlugin } from "@executor-js/sdk/client";
3+
4+import {
5+ CUSTOM_TOOLS_LABEL,
6+ CUSTOM_TOOLS_PLUGIN_KEY,
7+ type AppSourceKind,
8+} from "./custom-tools-client";
9+
10+const importAccounts = () => import("./CustomToolsAccountsPanel");
11+
12+export const makeAppsIntegrationPlugin = (config?: {
13+ readonly sourceKinds?: readonly AppSourceKind[];
14+}): IntegrationPlugin => {
15+ const sourceKinds = config?.sourceKinds ?? ["git"];
16+ const Add = lazy(async () => {
17+ const mod = await import("./AddCustomToolsSource");
18+ const AddCustomToolsSource = mod.default;
19+ return {
20+ default: (props: Parameters<typeof mod.default>[0]) => (
21+ <AddCustomToolsSource {...props} sourceKinds={sourceKinds} />
22+ ),
23+ };
24+ });
25+ const Accounts = lazy(importAccounts);
26+ return {
27+ key: CUSTOM_TOOLS_PLUGIN_KEY,
28+ label: CUSTOM_TOOLS_LABEL,
29+ add: Add,
30+ accounts: Accounts,
31+ preload: () => {
32+ void import("./AddCustomToolsSource");
33+ void importAccounts();
34+ },
35+ };
36+};
packages/plugins/apps/src/source/app-source.test.ts+6−0
@@ -227,6 +227,7 @@ describe("git app sources", () => { · 226 unmodified lines
227227 it.effect("rejects private git hosts under cloud posture", () =>
228228 Effect.gen(function* () {
229229 for (const url of [
230+ "http://localhost/repo.git",
230231 "https://localhost/repo.git",
231232 "https://127.0.0.1/repo.git",
232233 "https://[::ffff:127.0.0.1]/repo.git",
@@ -239,6 +240,11 @@ describe("git app sources", () => { · 6 unmodified lines
239240 const exit = yield* Effect.exit(parseGitSourceUrl(url));
240241 expect(Exit.isFailure(exit)).toBe(true);
241242 }
243+ expect(
244+ yield* parseGitSourceUrl("http://127.0.0.1:9999/repo.git", {
245+ allowPrivateHosts: true,
246+ }),
247+ ).toBeInstanceOf(URL);
242248 expect(yield* parseGitSourceUrl("https://gitlab.com/acme/tools.git")).toBeInstanceOf(URL);
243249 }),
244250 );
packages/plugins/apps/tsconfig.json+2−1
@@ -9,6 +9,7 @@ · 8 unmodified lines
99 "types": ["bun-types", "node"],
1010 "noUnusedLocals": true,
1111 "noImplicitOverride": true,
12+ "jsx": "react-jsx",
1213 "plugins": [
1314 {
1415 "name": "@effect/language-service",
@@ -18,5 +19,5 @@ · 3 unmodified lines
1819 }
1920 ]
2021 },
21− "include": ["src/**/*.ts"]
22+ "include": ["src/**/*.ts", "src/**/*.tsx"]
2223 }
packages/react/src/components/json-schema-form.tsx+31−8
@@ -95,6 +95,11 @@ const schemaDescription = (schema: JsonSchema): string | undefined => { · 94 unmodified lines
9595 return undefined;
9696 };
9797
98+export const emptyEnumGuidance = (description: string | undefined): string => {
99+ const integration = description?.match(/^Connection to use for .+ \(([^)]+)\)$/)?.[1];
100+ return integration ? `Connect ${integration} first.` : "No options available.";
101+};
102+
98103 const DEFAULT_BY_TYPE: Record<string, () => unknown> = {
99104 string: () => "",
100105 number: () => 0,
@@ -105,9 +110,10 @@ const DEFAULT_BY_TYPE: Record<string, () => unknown> = { · 4 unmodified lines
105110 };
106111
107112 /** Seed used when the user clicks "Add" for an optional property or array item.
108− * Never auto-injected on load — only on explicit add. */
113+ * Schema defaults can also be displayed without being injected into JSON. */
109114 export const defaultForSchema = (schema: JsonSchema, root: JsonSchema): unknown => {
110115 const s = deepResolve(schema, root);
116+ if (s.default !== undefined) return s.default;
111117 if (s.const !== undefined) return s.const;
112118 if (Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0];
113119 const t = primaryType(s);
@@ -223,8 +229,8 @@ export function JsonSchemaForm(props: { · 109 unmodified lines
223229 }
224230
225231 // ---------------------------------------------------------------------------
226−// ObjectFields — renders an object's properties (required first, then present
227−// optionals, then "Add" rows for absent optionals)
232+// ObjectFields — renders an object's properties (required/default-backed first,
233+// then present optionals, then "Add" rows for absent optionals)
228234 // ---------------------------------------------------------------------------
229235
230236 function ObjectFields(props: {
@@ -242,11 +248,15 @@ function ObjectFields(props: { · 11 unmodified lines
242248 const requiredSet = new Set(schema.required ?? []);
243249 const entries = Object.entries(properties);
244250
245− const present = entries.filter(
246− ([key]: [string, JsonSchema]) => key in value || requiredSet.has(key),
247− );
251+ const present = entries.filter(([key, propSchema]: [string, JsonSchema]) => {
252+ const resolved = deepResolve(propSchema, root);
253+ return key in value || requiredSet.has(key) || resolved.default !== undefined;
254+ });
248255 const addable = entries.filter(
249− ([key]: [string, JsonSchema]) => !requiredSet.has(key) && !(key in value),
256+ ([key, propSchema]: [string, JsonSchema]) =>
257+ !requiredSet.has(key) &&
258+ !(key in value) &&
259+ deepResolve(propSchema, root).default === undefined,
250260 );
251261
252262 // No properties → render nothing (no "takes no arguments" noise); a zero-arg
@@ -316,6 +326,7 @@ function JsonSchemaField(props: { · 63 unmodified lines
316326 const { root, name, fieldId, required, value, present, onChange, depth, disabled } = props;
317327 const schema = deepResolve(props.schema, root);
318328 const description = schemaDescription(schema);
329+ const fieldValue = value === undefined && schema.default !== undefined ? schema.default : value;
319330
320331 const header = (
321332 <div className="flex items-center justify-between gap-2">
@@ -353,7 +364,7 @@ function JsonSchemaField(props: { · 31 unmodified lines
353364 schema={schema}
354365 root={root}
355366 fieldId={fieldId}
356− value={value}
367+ value={fieldValue}
357368 onChange={onChange}
358369 depth={depth}
359370 disabled={disabled}
@@ -407,6 +418,18 @@ function FieldControl(props: { · 47 unmodified lines
407418 );
408419 }
409420
421+ if (Array.isArray(schema.enum) && schema.enum.length === 0) {
422+ const guidance = emptyEnumGuidance(schemaDescription(schema));
423+ return (
424+ <div className="space-y-1">
425+ <NativeSelect id={fieldId} className="w-full text-xs" value="" disabled={true}>
426+ <NativeSelectOption value="">{guidance}</NativeSelectOption>
427+ </NativeSelect>
428+ <p className="text-[11px] leading-4 text-muted-foreground">{guidance}</p>
429+ </div>
430+ );
431+ }
432+
410433 // enum — native select. Optional/unset gets a leading empty option.
411434 if (Array.isArray(schema.enum) && schema.enum.length > 0) {
412435 const options = schema.enum;
packages/react/src/lib/integration-detail-tabs.ts+16−0
@@ -0,0 +1,16 @@
1+export type IntegrationDetailSearchTab = "accounts" | "source" | "tools";
2+export type IntegrationDetailInternalTab = "accounts" | "tools";
3+
4+export const integrationDetailTabForAddCompletion = (
5+ pluginKey: string,
6+): IntegrationDetailSearchTab | undefined => (pluginKey === "apps" ? "source" : undefined);
7+
8+export const integrationDetailInternalTabFromSearch = (
9+ tab: IntegrationDetailSearchTab | undefined,
10+): IntegrationDetailInternalTab => (tab === "tools" ? "tools" : "accounts");
11+
12+export const integrationDetailSearchTabForInternal = (
13+ integrationKind: string | undefined,
14+ tab: IntegrationDetailInternalTab,
15+): IntegrationDetailSearchTab =>
16+ tab === "tools" ? "tools" : integrationKind === "apps" ? "source" : "accounts";
packages/react/src/pages/integration-add.tsx+11−1
@@ -1,8 +1,11 @@
11 import { Suspense } from "react";
2+import { useAtomRefresh } from "@effect/atom-react";
23 import { Link, useNavigate } from "@tanstack/react-router";
34 import { useIntegrationPlugins } from "@executor-js/sdk/client";
5+import { integrationsOptimisticAtom } from "../api/atoms";
46 import { trackEvent } from "../api/analytics";
57 import { useExecutorDocumentTitle } from "../lib/document-title";
8+import { integrationDetailTabForAddCompletion } from "../lib/integration-detail-tabs";
69
710 // ---------------------------------------------------------------------------
811 // Page
@@ -18,6 +21,7 @@ export function AddIntegrationPage(props: { · 9 unmodified lines
1821 const { pluginKey, url, preset, namespace } = props;
1922 const navigate = useNavigate();
2023 const integrationPlugins = useIntegrationPlugins();
24+ const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
2125
2226 const plugin = integrationPlugins.find((p) => p.key === pluginKey);
2327
@@ -59,9 +63,15 @@ export function AddIntegrationPage(props: { · 35 unmodified lines
5963 plugin_key: pluginKey,
6064 ...(slug ? { integration_slug: slug } : {}),
6165 });
66+ refreshIntegrations();
67+ const tab = integrationDetailTabForAddCompletion(pluginKey);
6268 void navigate(
6369 slug
64− ? { to: "/{-$orgSlug}/integrations/$namespace", params: { namespace: slug } }
70+ ? {
71+ to: "/{-$orgSlug}/integrations/$namespace",
72+ params: { namespace: slug },
73+ search: tab ? { tab } : {},
74+ }
6575 : { to: "/{-$orgSlug}" },
6676 );
6777 }}
packages/react/src/pages/integration-detail.tsx+41−5
@@ -36,6 +36,12 @@ import { Skeleton } from "../components/skeleton"; · 35 unmodified lines
3636 import { useExecutorDocumentTitle } from "../lib/document-title";
3737 import { ErrorState } from "../components/error-state";
3838 import { isAsyncResultLoading } from "../lib/async-result";
39+import {
40+ integrationDetailInternalTabFromSearch,
41+ integrationDetailSearchTabForInternal,
42+ type IntegrationDetailInternalTab,
43+ type IntegrationDetailSearchTab,
44+} from "../lib/integration-detail-tabs";
3945
4046 // v2: the route's `namespace` param is the integration slug. Tools belong to
4147 // the integration's per-owner connections; a tool's policy id is
@@ -52,7 +58,10 @@ type ToolRow = { · 10 unmodified lines
5258 readonly static?: boolean;
5359 };
5460
55−export function IntegrationDetailPage(props: { namespace: string }) {
61+export function IntegrationDetailPage(props: {
62+ namespace: string;
63+ tab?: IntegrationDetailSearchTab;
64+}) {
5665 const { namespace } = props;
5766 const slug = IntegrationSlug.make(namespace);
5867 const integrationPlugins = useIntegrationPlugins();
@@ -89,7 +98,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 30 unmodified lines
8998 const [deleting, setDeleting] = useState(false);
9099 const [refreshing, setRefreshing] = useState(false);
91100 const [editSheetOpen, setEditSheetOpen] = useState(false);
92− const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts");
101+ const [activeTab, setActiveTab] = useState<IntegrationDetailInternalTab>(() =>
102+ integrationDetailInternalTabFromSearch(props.tab),
103+ );
93104 const [manualAccountHandoff, setManualAccountHandoff] =
94105 useState<IntegrationAccountHandoff | null>(null);
95106 const [locationSearch] = useState(() =>
@@ -101,9 +112,14 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 5 unmodified lines
101112 setEditSheetOpen(false);
102113 }, [namespace]);
103114
115+ useEffect(() => {
116+ setActiveTab(integrationDetailInternalTabFromSearch(props.tab));
117+ }, [namespace, props.tab]);
118+
104119 const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;
105120 useExecutorDocumentTitle(integrationData?.name || namespace);
106121 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
122+ const isAppsIntegration = integrationData?.kind === "apps";
107123 const currentTab = isBuiltInIntegration ? "tools" : activeTab;
108124 const canRefresh = integrationData?.canRefresh ?? false;
109125 const canRemove = integrationData?.canRemove ?? false;
@@ -153,6 +169,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 43 unmodified lines
153169 // Find the plugin edit component based on integration kind
154170 const editPlugin = useMemo(() => {
155171 if (!integrationData) return null;
172+ if (integrationData.kind === "apps") {
173+ return integrationPlugins.find((p) => p.key === "apps") ?? null;
174+ }
156175 return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null;
157176 }, [integrationData, integrationPlugins]);
158177
@@ -347,6 +366,18 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 188 unmodified lines
347366 setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` });
348367 };
349368
369+ const handleTabChange = (value: string) => {
370+ const nextTab = value === "tools" ? "tools" : "accounts";
371+ setActiveTab(nextTab);
372+ void navigate({
373+ to: "/{-$orgSlug}/integrations/$namespace",
374+ params: { namespace },
375+ search: {
376+ tab: integrationDetailSearchTabForInternal(integrationData?.kind, nextTab),
377+ },
378+ });
379+ };
380+
350381 return (
351382 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
352383 {/* Header bar */}
@@ -363,7 +394,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 10 unmodified lines
363394 </div>
364395
365396 <div className="flex shrink-0 items-center gap-2">
366− {!confirmDelete && !isBuiltInIntegration && integrationData && (
397+ {!confirmDelete && !isBuiltInIntegration && !isAppsIntegration && integrationData && (
367398 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>
368399 Edit
369400 </Button>
@@ -381,6 +412,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 11 unmodified lines
381412 )}
382413
383414 {canRemove &&
415+ !isAppsIntegration &&
384416 (confirmDelete ? (
385417 <div className="flex items-center gap-2">
386418 <Button
@@ -415,12 +447,16 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 28 unmodified lines
415447
416448 <Tabs
417449 value={currentTab}
418− onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")}
450+ onValueChange={handleTabChange}
419451 className="min-h-0 flex-1 gap-0 overflow-hidden"
420452 >
421453 <div className="shrink-0 border-b border-border/60 px-4 py-2">
422454 <TabsList variant="line">
423− {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>}
455+ {!isBuiltInIntegration && (
456+ <TabsTrigger value="accounts">
457+ {isAppsIntegration ? "Source" : "Accounts"}
458+ </TabsTrigger>
459+ )}
424460 <TabsTrigger value="tools">Tools</TabsTrigger>
425461 </TabsList>
426462 </div>
packages/react/src/pages/integrations.tsx+40−0
@@ -49,6 +49,7 @@ import { ErrorState } from "../components/error-state"; · 48 unmodified lines
4949 import { isAsyncResultLoading } from "../lib/async-result";
5050
5151 const KIND_TO_PLUGIN_KEY: Record<string, string> = {
52+ apps: "apps",
5253 openapi: "openapi",
5354 mcp: "mcp",
5455 graphql: "graphql",
@@ -66,6 +67,29 @@ const bestDetection = ( · 11 unmodified lines
6667 ): IntegrationDetectionResult | undefined =>
6768 [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];
6869
70+const detectGitRepository = (
71+ raw: string,
72+): { readonly endpoint: string; readonly slug: string } | null => {
73+ const value = raw.trim();
74+ if (!URL.canParse(value)) return null;
75+ const parsed = new URL(value);
76+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
77+ if (parsed.username || parsed.password) return null;
78+ const segments = parsed.pathname.split("/").filter(Boolean);
79+ const name = segments.at(-1)?.replace(/\.git$/, "") ?? "";
80+ if (!name) return null;
81+ parsed.hash = "";
82+ return {
83+ endpoint: parsed.toString(),
84+ slug: name
85+ .trim()
86+ .toLowerCase()
87+ .replace(/[^a-z0-9-]+/g, "-")
88+ .replace(/^-+|-+$/g, "")
89+ .slice(0, 80),
90+ };
91+};
92+
6993 // ---------------------------------------------------------------------------
7094 // Page
7195 // ---------------------------------------------------------------------------
@@ -176,6 +200,22 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 104 unmodified lines
176200 if (!trimmed) return;
177201 setDetecting(true);
178202 setError(null);
203+ const gitRepository = detectGitRepository(trimmed);
204+ if (gitRepository && integrationPlugins.some((p) => p.key === "apps")) {
205+ trackEvent("integration_detect_submitted", {
206+ success: true,
207+ detected_kind: "apps",
208+ confidence: "high",
209+ });
210+ trackEvent("integration_add_started", { plugin_key: "apps", via: "detect" });
211+ closeAndReset();
212+ void navigate({
213+ to: "/{-$orgSlug}/integrations/add/$pluginKey",
214+ params: { pluginKey: "apps" },
215+ search: { url: gitRepository.endpoint, namespace: gitRepository.slug },
216+ });
217+ return;
218+ }
179219 // Detection is read-only — it inspects a URL and returns candidates without
180220 // mutating the catalog, so it invalidates nothing.
181221 const exit = await doDetect({
packages/react/src/routes/integrations.$namespace.tsx+16−1
@@ -1,10 +1,25 @@
1+import { Schema } from "effect";
12 import { createFileRoute } from "@tanstack/react-router";
23
34 import { IntegrationDetailPage } from "../pages/integration-detail";
5+import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs";
6+
7+const SearchParams = Schema.toStandardSchemaV1(
8+ Schema.Struct({
9+ tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])),
10+ }),
11+);
412
513 export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({
14+ validateSearch: SearchParams,
615 component: () => {
716 const { namespace } = Route.useParams();
8− return <IntegrationDetailPage namespace={namespace} />;
17+ const { tab } = Route.useSearch();
18+ return (
19+ <IntegrationDetailPage
20+ namespace={namespace}
21+ tab={tab as IntegrationDetailSearchTab | undefined}
22+ />
23+ );
924 },
1025 });