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
801 "zod": "4.3.6",801 "zod": "4.3.6",
802 },802 },
803 "devDependencies": {803 "devDependencies": {
804 "@effect/atom-react": "catalog:",
804 "@effect/vitest": "catalog:",805 "@effect/vitest": "catalog:",
806 "@executor-js/react": "workspace:*",
805 "@types/node": "catalog:",807 "@types/node": "catalog:",
808 "@types/react": "catalog:",
806 "bun-types": "catalog:",809 "bun-types": "catalog:",
807 "effect": "catalog:",810 "effect": "catalog:",
811 "react": "catalog:",
808 "tsup": "catalog:",812 "tsup": "catalog:",
809 "vitest": "catalog:",813 "vitest": "catalog:",
810 },814 },
811 "peerDependencies": {815 "peerDependencies": {
816 "@effect/atom-react": "catalog:",
817 "@executor-js/react": "workspace:*",
818 "@tanstack/react-router": "catalog:",
812 "effect": "catalog:",819 "effect": "catalog:",
820 "react": "catalog:",
813 },821 },
822 "optionalPeers": [
823 "@effect/atom-react",
824 "@executor-js/react",
825 "@tanstack/react-router",
826 "react",
827 ],
814 },828 },
815 "packages/plugins/desktop-settings": {829 "packages/plugins/desktop-settings": {
816 "name": "@executor-js/plugin-desktop-settings",830 "name": "@executor-js/plugin-desktop-settings",
e2e/fixtures/custom-tools-git/custom-tools-shas.txt+2−0
@@ -0,0 +1,2 @@
182609ee4ee2acbeb9056ee96a21b24bcee573aac
20747e7274d47ee678e314020d3e1f670b57fb8fd
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 */
2import { readFile } from "node:fs/promises";
3import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4import { join } from "node:path";
5
6import { expect } from "@effect/vitest";
7import { Effect } from "effect";
8
9import { scenario } from "../src/scenario";
10import { Api, Browser, Target } from "../src/services";
11import type { Identity, Target as TargetShape } from "../src/target";
12import type { BrowserSurface } from "../src/surfaces/browser";
13
14const textEncoder = new TextEncoder();
15
16const 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
26const 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
32const FLUSH = textEncoder.encode("0000");
33
34const 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
47const 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
58const 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
69const 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
106interface ToolRow {
107 readonly address: string;
108 readonly name: string;
109 readonly integration: string;
110}
111
112interface ExecuteResponse {
113 readonly status: "completed" | "paused";
114 readonly text: string;
115 readonly structured: unknown;
116 readonly isError?: boolean;
117}
118
119const 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
137const 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
158const 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
176const 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
183const 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
189const 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
218const 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
239const 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
255scenario(
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
362 362
363describe("tool catalog sync safety", () => {363describe("tool catalog sync safety", () => {
364 it.effect(364 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",
366 () =>366 () =>
367 Effect.scoped(367 Effect.scoped(
368 Effect.gen(function* () {368 Effect.gen(function* () {
369 let empty = false;369 let empty = false;
370 const guardedPlugin = definePlugin(() => ({370 const guardedPlugin = definePlugin(() => ({
371 id: "guarded" as const,371 id: "guarded" as const,
372 remoteToolCatalog: true,
372 credentialProviders: [memoryProvider()],373 credentialProviders: [memoryProvider()],
373 storage: () => ({}),374 storage: () => ({}),
374 resolveTools: () =>375 resolveTools: () =>
@@ -424,6 +425,66 @@ describe("tool catalog sync safety", () => { · 49 unmodified lines
424 ),425 ),
425 );426 );
426 427
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
427 it.effect("explicit refresh accepts an authoritative empty catalog", () =>488 it.effect("explicit refresh accepts an authoritative empty catalog", () =>
428 Effect.scoped(489 Effect.scoped(
429 Effect.gen(function* () {490 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
2167 return keptRows.map((row) => rowToTool(row as ConnectionToolRow));2167 return keptRows.map((row) => rowToTool(row as ConnectionToolRow));
2168 }2168 }
2169 2169
2170 if (mode === "background" && result.tools.length === 0) {2170 if (
2171 mode === "background" &&
2172 runtime.plugin.remoteToolCatalog === true &&
2173 result.tools.length === 0
2174 ) {
2171 const keptRows = yield* core.findMany("tool", { where });2175 const keptRows = yield* core.findMany("tool", { where });
2172 if (keptRows.length > 0) {2176 if (keptRows.length > 0) {
2173 const reason =2177 const reason =
packages/plugins/apps/package.json+25−1
@@ -20,6 +20,8 @@ · 19 unmodified lines
20 "./api": "./src/api.ts",20 "./api": "./src/api.ts",
21 "./authoring": "./src/authoring.ts",21 "./authoring": "./src/authoring.ts",
22 "./cloud": "./src/cloud.ts",22 "./cloud": "./src/cloud.ts",
23 "./client": "./src/react/plugin-client.tsx",
24 "./react": "./src/react/index.ts",
23 "./selfhost": "./src/selfhost.ts",25 "./selfhost": "./src/selfhost.ts",
24 "./testing": "./src/testing/index.ts"26 "./testing": "./src/testing/index.ts"
25 },27 },
@@ -39,14 +41,36 @@ · 13 unmodified lines
39 "zod": "4.3.6"41 "zod": "4.3.6"
40 },42 },
41 "devDependencies": {43 "devDependencies": {
44 "@effect/atom-react": "catalog:",
42 "@effect/vitest": "catalog:",45 "@effect/vitest": "catalog:",
46 "@executor-js/react": "workspace:*",
43 "@types/node": "catalog:",47 "@types/node": "catalog:",
48 "@types/react": "catalog:",
44 "bun-types": "catalog:",49 "bun-types": "catalog:",
45 "effect": "catalog:",50 "effect": "catalog:",
51 "react": "catalog:",
46 "tsup": "catalog:",52 "tsup": "catalog:",
47 "vitest": "catalog:"53 "vitest": "catalog:"
48 },54 },
49 "peerDependencies": {55 "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 }
51 }75 }
52}76}
packages/plugins/apps/src/git-client/url-security.ts+8−2
@@ -136,12 +136,18 @@ export const isPrivateGitHost = (host: string): boolean => { · 135 unmodified lines
136 136
137export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => {137export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => {
138 const url = new URL(input);138 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 }
140 if (url.username || url.password)146 if (url.username || url.password)
141 throw new Error("git source URL must not include embedded credentials");147 throw new Error("git source URL must not include embedded credentials");
142 if (hasTokenLikeQuery(url))148 if (hasTokenLikeQuery(url))
143 throw new Error("git source URL must not include token query parameters");149 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) {
145 throw new Error("git source URL host is not allowed");151 throw new Error("git source URL host is not allowed");
146 }152 }
147 return url;153 return url;
packages/plugins/apps/src/pipeline/publish.test.ts+12−1
@@ -58,7 +58,18 @@ const makeMemoryStore = (): AppsStore & { · 57 unmodified lines
58 for (const key of rows.keys()) rows.delete(key);58 for (const key of rows.keys()) rows.delete(key);
59 for (const item of next.tools) rows.set(item.name, item);59 for (const item of next.tools) rows.set(item.name, item);
60 }),60 }),
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 ),
62 getTool: (name) =>73 getTool: (name) =>
63 Effect.sync(() => {74 Effect.sync(() => {
64 const row = descriptor?.tools.find((item) => item.name === name);75 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
74 descriptor = next;74 descriptor = next;
75 descriptorKey = nextDescriptorKey;75 descriptorKey = nextDescriptorKey;
76 }),76 }),
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 ),
78 getTool: (name) =>88 getTool: (name) =>
79 Effect.sync(() => {89 Effect.sync(() => {
80 const tool = descriptor?.tools.find((item) => item.name === name);90 const tool = descriptor?.tools.find((item) => item.name === name);
@@ -145,6 +155,7 @@ const makeInvokeStore = (input: { · 64 unmodified lines
145 getBlob: () => Effect.succeed(input.bundle),155 getBlob: () => Effect.succeed(input.bundle),
146 getDescriptorRecord: () => Effect.succeed(null),156 getDescriptorRecord: () => Effect.succeed(null),
147 putPublished: () => Effect.void,157 putPublished: () => Effect.void,
158 removePublished: () => Effect.void,
148 listActiveTools: () => Effect.succeed([]),159 listActiveTools: () => Effect.succeed([]),
149 getTool: () =>160 getTool: () =>
150 Effect.succeed({161 Effect.succeed({
@@ -190,6 +201,7 @@ describe("apps plugin schema projection", () => { · 39 unmodified lines
190 getBlob: () => Effect.succeed(null),201 getBlob: () => Effect.succeed(null),
191 getDescriptorRecord: () => Effect.succeed(null),202 getDescriptorRecord: () => Effect.succeed(null),
192 putPublished: () => Effect.void,203 putPublished: () => Effect.void,
204 removePublished: () => Effect.void,
193 listActiveTools: () => Effect.succeed([]),205 listActiveTools: () => Effect.succeed([]),
194 putSource: () => Effect.void,206 putSource: () => Effect.void,
195 listSources: () => Effect.succeed([]),207 listSources: () => Effect.succeed([]),
@@ -233,14 +245,18 @@ describe("apps plugin schema projection", () => { · 37 unmodified lines
233 );245 );
234 expect(result.inputSchema).toMatchObject({246 expect(result.inputSchema).toMatchObject({
235 properties: {247 properties: {
236 crm: { enum: ["tools.dealcloud.org.main"] },248 crm: {
249 enum: ["tools.dealcloud.org.main"],
250 default: "tools.dealcloud.org.main",
251 },
237 inboxes: {252 inboxes: {
253 default: ["tools.gmail.org.work", "tools.gmail.user.personal"],
238 items: {254 items: {
239 enum: ["tools.gmail.org.work", "tools.gmail.user.personal"],255 enum: ["tools.gmail.org.work", "tools.gmail.user.personal"],
240 },256 },
241 },257 },
242 },258 },
243 required: ["crm", "inboxes"],259 required: undefined,
244 });260 });
245 }),261 }),
246 );262 );
packages/plugins/apps/src/plugin/apps-plugin.ts+32−10
@@ -207,13 +207,7 @@ const activeToolNamesFor = ( · 206 unmodified lines
207): Effect.Effect<readonly string[], unknown> =>207): Effect.Effect<readonly string[], unknown> =>
208 ctx.storage208 ctx.storage
209 .listActiveTools()209 .listActiveTools()
210 .pipe(210 .pipe(Effect.map((tools) => tools.filter((tool) => tool.app === app).map((tool) => tool.name)));
211 Effect.map((tools) =>
212 tools
213 .filter((tool) => tool.name.startsWith(`${app}__`) || tool.name === app)
214 .map((tool) => tool.name),
215 ),
216 );
217 211
218const makeAppsExtension = (212const makeAppsExtension = (
219 ctx: PluginCtx<AppsStore>,213 ctx: PluginCtx<AppsStore>,
@@ -281,6 +275,14 @@ const makeAppsExtension = ( · 61 unmodified lines
281 deleteSource: (slug: string) =>275 deleteSource: (slug: string) =>
282 Effect.gen(function* () {276 Effect.gen(function* () {
283 const record = yield* ctx.storage.getSource(slug);277 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 }
284 if (286 if (
285 record?.config.kind === "git" &&287 record?.config.kind === "git" &&
286 record.config.tokenProvider &&288 record.config.tokenProvider &&
@@ -435,6 +437,7 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 148 unmodified lines
435 definePlugin(() => ({437 definePlugin(() => ({
436 id: "apps",438 id: "apps",
437 packageName: "@executor-js/plugin-apps",439 packageName: "@executor-js/plugin-apps",
440 clientConfig: { sourceKinds: options?.sourceKinds ?? ["git", "local-directory"] },
438 pluginStorage: {441 pluginStorage: {
439 [descriptorCollection.name]: descriptorCollection,442 [descriptorCollection.name]: descriptorCollection,
440 [toolCollection.name]: toolCollection,443 [toolCollection.name]: toolCollection,
@@ -546,10 +549,29 @@ export const projectAppsToolSchema = ( · 105 unmodified lines
546 const enumValues = connections.map((connection) => String(connection.address));549 const enumValues = connections.map((connection) => String(connection.address));
547 properties[field] =550 properties[field] =
548 decl.mode === "many"551 decl.mode === "many"
549 ? { type: "array", items: { type: "string", enum: enumValues } }552 ? {
550 : { type: "string", enum: enumValues };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 };
551 }562 }
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 };
553 });575 });
554 576
555export const appsPlugin = makeAppsPlugin();577export const appsPlugin = makeAppsPlugin();
packages/plugins/apps/src/plugin/store.test.ts+21−0
@@ -177,6 +177,27 @@ describe("apps store", () => { · 176 unmodified lines
177 yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1");177 yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1");
178 const active = yield* store.listActiveTools();178 const active = yield* store.listActiveTools();
179 expect(active.map((item) => item.name)).toEqual(["first"]);179 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();
180 }),201 }),
181 );202 );
182 203
packages/plugins/apps/src/plugin/store.ts+19−1
@@ -102,8 +102,9 @@ export interface AppsStore { · 101 unmodified lines
102 owner: Owner,102 owner: Owner,
103 expectedSourceRef: string | null,103 expectedSourceRef: string | null,
104 ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>;104 ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>;
105 readonly removePublished: (app: string, owner: Owner) => Effect.Effect<void, StorageFailure>;
105 readonly listActiveTools: () => Effect.Effect<106 readonly listActiveTools: () => Effect.Effect<
106 readonly AppDescriptor["tools"][number][],107 readonly (AppDescriptor["tools"][number] & { readonly app: string })[],
107 StorageFailure108 StorageFailure
108 >;109 >;
109 readonly getTool: (name: string) => Effect.Effect<110 readonly getTool: (name: string) => Effect.Effect<
@@ -215,10 +216,27 @@ export const makeAppsStore = (input: { · 105 unmodified lines
215 },216 },
216 });217 });
217 }),218 }),
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 }),
218 listActiveTools: () =>235 listActiveTools: () =>
219 tools.query({ where: { tombstoned: false } }).pipe(236 tools.query({ where: { tombstoned: false } }).pipe(
220 Effect.map((entries) =>237 Effect.map((entries) =>
221 entries.map((entry) => ({238 entries.map((entry) => ({
239 app: entry.data.app,
222 name: entry.data.name,240 name: entry.data.name,
223 sourcePath: "",241 sourcePath: "",
224 source: { path: "", sourceHash: "" },242 source: { path: "", sourceHash: "" },
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+264−0
@@ -0,0 +1,264 @@
1import { useMemo, useState } from "react";
2import { Effect, Exit } from "effect";
3
4import { Button } from "@executor-js/react/components/button";
5import {
6 CardStack,
7 CardStackContent,
8 CardStackEntryField,
9} from "@executor-js/react/components/card-stack";
10import { FloatActions } from "@executor-js/react/components/float-actions";
11import { Input } from "@executor-js/react/components/input";
12import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add";
13
14import {
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
27export 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 @@
1import { useEffect, useState } from "react";
2import { Effect, Exit } from "effect";
3
4import {
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";
15import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
16import { Button } from "@executor-js/react/components/button";
17import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
18
19import {
20 listCustomToolSourcesEffect,
21 removeCustomToolSourceEffect,
22 syncCustomToolSourceEffect,
23 type AppSourceRecord,
24} from "./custom-tools-client";
25import {
26 sourceFailureLines,
27 sourcePanelModel,
28 syncNoticeFromResult,
29 toolsCountLabel,
30 type SyncNoticeModel,
31} from "./source-panel-model";
32
33const integrationsHref = (): string => {
34 const match = window.location.pathname.match(/^(.*)\/integrations(?:\/.*)?$/);
35 return `${match?.[1] ?? ""}/integrations`;
36};
37
38type LoadState =
39 | { readonly status: "loading" }
40 | { readonly status: "error"; readonly message: string }
41 | { readonly status: "ready"; readonly sources: readonly AppSourceRecord[] };
42
43export 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
168function 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
276function 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 @@
1import { Data, Effect, Schema } from "effect";
2
3export const CUSTOM_TOOLS_PLUGIN_KEY = "apps";
4export const CUSTOM_TOOLS_LABEL = "Custom tools";
5export const APPS_INTEGRATION_SLUG = "apps";
6
7export type AppSourceKind = "git" | "local-directory";
8
9export 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
15export 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
24export 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
52export interface SourcesListResponse {
53 readonly sources: readonly AppSourceRecord[];
54}
55
56export interface SourceDetailResponse {
57 readonly source: AppSourceRecord | null;
58}
59
60export 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
76export interface CreateSourceResponse {
77 readonly source: AppSourceRecord;
78}
79
80export 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
94export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
95
96export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{
97 readonly message: string;
98}> {}
99
100export 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
108export 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
116export 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
138export 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
145export const validateGitSourceUrl = (url: string): string | null => {
146 const parsed = parseGitSourceUrl(url);
147 return parsed.ok ? null : parsed.message;
148};
149
150const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
151const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
152
153const isRecord = (value: unknown): value is Record<string, unknown> =>
154 value !== null && typeof value === "object" && !Array.isArray(value);
155
156const 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
163const 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
186export 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
201export const listCustomToolSources = (
202 fetchImpl: CustomToolsFetch = fetch,
203): Promise<SourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl));
204
205export 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
224export 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
246export 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
263export 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
283export 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
289export 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
298export const formatSyncErrors = (result: SyncSourceResult): readonly string[] =>
299 result.status === "failed" ? formatDiagnostics(result.errors) : [];
300
301export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3
4import 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";
20import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model";
21
22const 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
29const 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
50describe("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 @@
1export { default } from "./plugin-client";
2export * from "./custom-tools-client";
packages/plugins/apps/src/react/plugin-client.tsx+16−0
@@ -0,0 +1,16 @@
1import { defineClientPlugin } from "@executor-js/sdk/client";
2
3import { makeAppsIntegrationPlugin } from "./source-plugin";
4import type { AppSourceKind } from "./custom-tools-client";
5
6export { makeAppsIntegrationPlugin } from "./source-plugin";
7export * from "./custom-tools-client";
8
9export 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 @@
1import type { AppSourceRecord, SyncSourceResult } from "./custom-tools-client";
2import {
3 formatDiagnostics,
4 formatSyncErrors,
5 syncStatusLabel,
6 toolDiff,
7} from "./custom-tools-client";
8
9export 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
17export 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
26const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced");
27
28export 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
40export const toolsCountLabel = (count: number): string =>
41 `${count} ${count === 1 ? "tool" : "tools"}`;
42
43export 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
71export const sourceFailureLines = (source: AppSourceRecord): readonly string[] =>
72 source.status.type === "failed" ? formatDiagnostics(source.status.errors) : [];
73
74export 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 @@
1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";
3
4import {
5 CUSTOM_TOOLS_LABEL,
6 CUSTOM_TOOLS_PLUGIN_KEY,
7 type AppSourceKind,
8} from "./custom-tools-client";
9
10const importAccounts = () => import("./CustomToolsAccountsPanel");
11
12export 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
227 it.effect("rejects private git hosts under cloud posture", () =>227 it.effect("rejects private git hosts under cloud posture", () =>
228 Effect.gen(function* () {228 Effect.gen(function* () {
229 for (const url of [229 for (const url of [
230 "http://localhost/repo.git",
230 "https://localhost/repo.git",231 "https://localhost/repo.git",
231 "https://127.0.0.1/repo.git",232 "https://127.0.0.1/repo.git",
232 "https://[::ffff:127.0.0.1]/repo.git",233 "https://[::ffff:127.0.0.1]/repo.git",
@@ -239,6 +240,11 @@ describe("git app sources", () => { · 6 unmodified lines
239 const exit = yield* Effect.exit(parseGitSourceUrl(url));240 const exit = yield* Effect.exit(parseGitSourceUrl(url));
240 expect(Exit.isFailure(exit)).toBe(true);241 expect(Exit.isFailure(exit)).toBe(true);
241 }242 }
243 expect(
244 yield* parseGitSourceUrl("http://127.0.0.1:9999/repo.git", {
245 allowPrivateHosts: true,
246 }),
247 ).toBeInstanceOf(URL);
242 expect(yield* parseGitSourceUrl("https://gitlab.com/acme/tools.git")).toBeInstanceOf(URL);248 expect(yield* parseGitSourceUrl("https://gitlab.com/acme/tools.git")).toBeInstanceOf(URL);
243 }),249 }),
244 );250 );
packages/plugins/apps/tsconfig.json+2−1
@@ -9,6 +9,7 @@ · 8 unmodified lines
9 "types": ["bun-types", "node"],9 "types": ["bun-types", "node"],
10 "noUnusedLocals": true,10 "noUnusedLocals": true,
11 "noImplicitOverride": true,11 "noImplicitOverride": true,
12 "jsx": "react-jsx",
12 "plugins": [13 "plugins": [
13 {14 {
14 "name": "@effect/language-service",15 "name": "@effect/language-service",
@@ -18,5 +19,5 @@ · 3 unmodified lines
18 }19 }
19 ]20 ]
20 },21 },
21 "include": ["src/**/*.ts"]22 "include": ["src/**/*.ts", "src/**/*.tsx"]
22}23}
packages/react/src/components/json-schema-form.tsx+31−8
@@ -95,6 +95,11 @@ const schemaDescription = (schema: JsonSchema): string | undefined => { · 94 unmodified lines
95 return undefined;95 return undefined;
96};96};
97 97
98export 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
98const DEFAULT_BY_TYPE: Record<string, () => unknown> = {103const DEFAULT_BY_TYPE: Record<string, () => unknown> = {
99 string: () => "",104 string: () => "",
100 number: () => 0,105 number: () => 0,
@@ -105,9 +110,10 @@ const DEFAULT_BY_TYPE: Record<string, () => unknown> = { · 4 unmodified lines
105};110};
106 111
107/** Seed used when the user clicks "Add" for an optional property or array item.112/** 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. */
109export const defaultForSchema = (schema: JsonSchema, root: JsonSchema): unknown => {114export const defaultForSchema = (schema: JsonSchema, root: JsonSchema): unknown => {
110 const s = deepResolve(schema, root);115 const s = deepResolve(schema, root);
116 if (s.default !== undefined) return s.default;
111 if (s.const !== undefined) return s.const;117 if (s.const !== undefined) return s.const;
112 if (Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0];118 if (Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0];
113 const t = primaryType(s);119 const t = primaryType(s);
@@ -223,8 +229,8 @@ export function JsonSchemaForm(props: { · 109 unmodified lines
223}229}
224 230
225// ---------------------------------------------------------------------------231// ---------------------------------------------------------------------------
226// ObjectFields — renders an object's properties (required first, then present232// ObjectFields — renders an object's properties (required/default-backed first,
227// optionals, then "Add" rows for absent optionals)233// then present optionals, then "Add" rows for absent optionals)
228// ---------------------------------------------------------------------------234// ---------------------------------------------------------------------------
229 235
230function ObjectFields(props: {236function ObjectFields(props: {
@@ -242,11 +248,15 @@ function ObjectFields(props: { · 11 unmodified lines
242 const requiredSet = new Set(schema.required ?? []);248 const requiredSet = new Set(schema.required ?? []);
243 const entries = Object.entries(properties);249 const entries = Object.entries(properties);
244 250
245 const present = entries.filter(251 const present = entries.filter(([key, propSchema]: [string, JsonSchema]) => {
246 ([key]: [string, JsonSchema]) => key in value || requiredSet.has(key),252 const resolved = deepResolve(propSchema, root);
247 );253 return key in value || requiredSet.has(key) || resolved.default !== undefined;
254 });
248 const addable = entries.filter(255 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,
250 );260 );
251 261
252 // No properties → render nothing (no "takes no arguments" noise); a zero-arg262 // No properties → render nothing (no "takes no arguments" noise); a zero-arg
@@ -316,6 +326,7 @@ function JsonSchemaField(props: { · 63 unmodified lines
316 const { root, name, fieldId, required, value, present, onChange, depth, disabled } = props;326 const { root, name, fieldId, required, value, present, onChange, depth, disabled } = props;
317 const schema = deepResolve(props.schema, root);327 const schema = deepResolve(props.schema, root);
318 const description = schemaDescription(schema);328 const description = schemaDescription(schema);
329 const fieldValue = value === undefined && schema.default !== undefined ? schema.default : value;
319 330
320 const header = (331 const header = (
321 <div className="flex items-center justify-between gap-2">332 <div className="flex items-center justify-between gap-2">
@@ -353,7 +364,7 @@ function JsonSchemaField(props: { · 31 unmodified lines
353 schema={schema}364 schema={schema}
354 root={root}365 root={root}
355 fieldId={fieldId}366 fieldId={fieldId}
356 value={value}367 value={fieldValue}
357 onChange={onChange}368 onChange={onChange}
358 depth={depth}369 depth={depth}
359 disabled={disabled}370 disabled={disabled}
@@ -407,6 +418,18 @@ function FieldControl(props: { · 47 unmodified lines
407 );418 );
408 }419 }
409 420
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
410 // enum — native select. Optional/unset gets a leading empty option.433 // enum — native select. Optional/unset gets a leading empty option.
411 if (Array.isArray(schema.enum) && schema.enum.length > 0) {434 if (Array.isArray(schema.enum) && schema.enum.length > 0) {
412 const options = schema.enum;435 const options = schema.enum;
packages/react/src/lib/integration-detail-tabs.ts+16−0
@@ -0,0 +1,16 @@
1export type IntegrationDetailSearchTab = "accounts" | "source" | "tools";
2export type IntegrationDetailInternalTab = "accounts" | "tools";
3
4export const integrationDetailTabForAddCompletion = (
5 pluginKey: string,
6): IntegrationDetailSearchTab | undefined => (pluginKey === "apps" ? "source" : undefined);
7
8export const integrationDetailInternalTabFromSearch = (
9 tab: IntegrationDetailSearchTab | undefined,
10): IntegrationDetailInternalTab => (tab === "tools" ? "tools" : "accounts");
11
12export 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 @@
1import { Suspense } from "react";1import { Suspense } from "react";
2import { useAtomRefresh } from "@effect/atom-react";
2import { Link, useNavigate } from "@tanstack/react-router";3import { Link, useNavigate } from "@tanstack/react-router";
3import { useIntegrationPlugins } from "@executor-js/sdk/client";4import { useIntegrationPlugins } from "@executor-js/sdk/client";
5import { integrationsOptimisticAtom } from "../api/atoms";
4import { trackEvent } from "../api/analytics";6import { trackEvent } from "../api/analytics";
5import { useExecutorDocumentTitle } from "../lib/document-title";7import { useExecutorDocumentTitle } from "../lib/document-title";
8import { integrationDetailTabForAddCompletion } from "../lib/integration-detail-tabs";
6 9
7// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
8// Page11// Page
@@ -18,6 +21,7 @@ export function AddIntegrationPage(props: { · 9 unmodified lines
18 const { pluginKey, url, preset, namespace } = props;21 const { pluginKey, url, preset, namespace } = props;
19 const navigate = useNavigate();22 const navigate = useNavigate();
20 const integrationPlugins = useIntegrationPlugins();23 const integrationPlugins = useIntegrationPlugins();
24 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
21 25
22 const plugin = integrationPlugins.find((p) => p.key === pluginKey);26 const plugin = integrationPlugins.find((p) => p.key === pluginKey);
23 27
@@ -59,9 +63,15 @@ export function AddIntegrationPage(props: { · 35 unmodified lines
59 plugin_key: pluginKey,63 plugin_key: pluginKey,
60 ...(slug ? { integration_slug: slug } : {}),64 ...(slug ? { integration_slug: slug } : {}),
61 });65 });
66 refreshIntegrations();
67 const tab = integrationDetailTabForAddCompletion(pluginKey);
62 void navigate(68 void navigate(
63 slug69 slug
64 ? { to: "/{-$orgSlug}/integrations/$namespace", params: { namespace: slug } }70 ? {
71 to: "/{-$orgSlug}/integrations/$namespace",
72 params: { namespace: slug },
73 search: tab ? { tab } : {},
74 }
65 : { to: "/{-$orgSlug}" },75 : { to: "/{-$orgSlug}" },
66 );76 );
67 }}77 }}
packages/react/src/pages/integration-detail.tsx+41−5
@@ -36,6 +36,12 @@ import { Skeleton } from "../components/skeleton"; · 35 unmodified lines
36import { useExecutorDocumentTitle } from "../lib/document-title";36import { useExecutorDocumentTitle } from "../lib/document-title";
37import { ErrorState } from "../components/error-state";37import { ErrorState } from "../components/error-state";
38import { isAsyncResultLoading } from "../lib/async-result";38import { isAsyncResultLoading } from "../lib/async-result";
39import {
40 integrationDetailInternalTabFromSearch,
41 integrationDetailSearchTabForInternal,
42 type IntegrationDetailInternalTab,
43 type IntegrationDetailSearchTab,
44} from "../lib/integration-detail-tabs";
39 45
40// v2: the route's `namespace` param is the integration slug. Tools belong to46// v2: the route's `namespace` param is the integration slug. Tools belong to
41// the integration's per-owner connections; a tool's policy id is47// the integration's per-owner connections; a tool's policy id is
@@ -52,7 +58,10 @@ type ToolRow = { · 10 unmodified lines
52 readonly static?: boolean;58 readonly static?: boolean;
53};59};
54 60
55export function IntegrationDetailPage(props: { namespace: string }) {61export function IntegrationDetailPage(props: {
62 namespace: string;
63 tab?: IntegrationDetailSearchTab;
64}) {
56 const { namespace } = props;65 const { namespace } = props;
57 const slug = IntegrationSlug.make(namespace);66 const slug = IntegrationSlug.make(namespace);
58 const integrationPlugins = useIntegrationPlugins();67 const integrationPlugins = useIntegrationPlugins();
@@ -89,7 +98,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 30 unmodified lines
89 const [deleting, setDeleting] = useState(false);98 const [deleting, setDeleting] = useState(false);
90 const [refreshing, setRefreshing] = useState(false);99 const [refreshing, setRefreshing] = useState(false);
91 const [editSheetOpen, setEditSheetOpen] = useState(false);100 const [editSheetOpen, setEditSheetOpen] = useState(false);
92 const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts");101 const [activeTab, setActiveTab] = useState<IntegrationDetailInternalTab>(() =>
102 integrationDetailInternalTabFromSearch(props.tab),
103 );
93 const [manualAccountHandoff, setManualAccountHandoff] =104 const [manualAccountHandoff, setManualAccountHandoff] =
94 useState<IntegrationAccountHandoff | null>(null);105 useState<IntegrationAccountHandoff | null>(null);
95 const [locationSearch] = useState(() =>106 const [locationSearch] = useState(() =>
@@ -101,9 +112,14 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 5 unmodified lines
101 setEditSheetOpen(false);112 setEditSheetOpen(false);
102 }, [namespace]);113 }, [namespace]);
103 114
115 useEffect(() => {
116 setActiveTab(integrationDetailInternalTabFromSearch(props.tab));
117 }, [namespace, props.tab]);
118
104 const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;119 const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;
105 useExecutorDocumentTitle(integrationData?.name || namespace);120 useExecutorDocumentTitle(integrationData?.name || namespace);
106 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";121 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
122 const isAppsIntegration = integrationData?.kind === "apps";
107 const currentTab = isBuiltInIntegration ? "tools" : activeTab;123 const currentTab = isBuiltInIntegration ? "tools" : activeTab;
108 const canRefresh = integrationData?.canRefresh ?? false;124 const canRefresh = integrationData?.canRefresh ?? false;
109 const canRemove = integrationData?.canRemove ?? false;125 const canRemove = integrationData?.canRemove ?? false;
@@ -153,6 +169,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 43 unmodified lines
153 // Find the plugin edit component based on integration kind169 // Find the plugin edit component based on integration kind
154 const editPlugin = useMemo(() => {170 const editPlugin = useMemo(() => {
155 if (!integrationData) return null;171 if (!integrationData) return null;
172 if (integrationData.kind === "apps") {
173 return integrationPlugins.find((p) => p.key === "apps") ?? null;
174 }
156 return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null;175 return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null;
157 }, [integrationData, integrationPlugins]);176 }, [integrationData, integrationPlugins]);
158 177
@@ -347,6 +366,18 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 188 unmodified lines
347 setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` });366 setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` });
348 };367 };
349 368
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
350 return (381 return (
351 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">382 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
352 {/* Header bar */}383 {/* Header bar */}
@@ -363,7 +394,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 10 unmodified lines
363 </div>394 </div>
364 395
365 <div className="flex shrink-0 items-center gap-2">396 <div className="flex shrink-0 items-center gap-2">
366 {!confirmDelete && !isBuiltInIntegration && integrationData && (397 {!confirmDelete && !isBuiltInIntegration && !isAppsIntegration && integrationData && (
367 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>398 <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>
368 Edit399 Edit
369 </Button>400 </Button>
@@ -381,6 +412,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 11 unmodified lines
381 )}412 )}
382 413
383 {canRemove &&414 {canRemove &&
415 !isAppsIntegration &&
384 (confirmDelete ? (416 (confirmDelete ? (
385 <div className="flex items-center gap-2">417 <div className="flex items-center gap-2">
386 <Button418 <Button
@@ -415,12 +447,16 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 28 unmodified lines
415 447
416 <Tabs448 <Tabs
417 value={currentTab}449 value={currentTab}
418 onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")}450 onValueChange={handleTabChange}
419 className="min-h-0 flex-1 gap-0 overflow-hidden"451 className="min-h-0 flex-1 gap-0 overflow-hidden"
420 >452 >
421 <div className="shrink-0 border-b border-border/60 px-4 py-2">453 <div className="shrink-0 border-b border-border/60 px-4 py-2">
422 <TabsList variant="line">454 <TabsList variant="line">
423 {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>}455 {!isBuiltInIntegration && (
456 <TabsTrigger value="accounts">
457 {isAppsIntegration ? "Source" : "Accounts"}
458 </TabsTrigger>
459 )}
424 <TabsTrigger value="tools">Tools</TabsTrigger>460 <TabsTrigger value="tools">Tools</TabsTrigger>
425 </TabsList>461 </TabsList>
426 </div>462 </div>
packages/react/src/pages/integrations.tsx+40−0
@@ -49,6 +49,7 @@ import { ErrorState } from "../components/error-state"; · 48 unmodified lines
49import { isAsyncResultLoading } from "../lib/async-result";49import { isAsyncResultLoading } from "../lib/async-result";
50 50
51const KIND_TO_PLUGIN_KEY: Record<string, string> = {51const KIND_TO_PLUGIN_KEY: Record<string, string> = {
52 apps: "apps",
52 openapi: "openapi",53 openapi: "openapi",
53 mcp: "mcp",54 mcp: "mcp",
54 graphql: "graphql",55 graphql: "graphql",
@@ -66,6 +67,29 @@ const bestDetection = ( · 11 unmodified lines
66): IntegrationDetectionResult | undefined =>67): IntegrationDetectionResult | undefined =>
67 [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];68 [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];
68 69
70const 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
69// ---------------------------------------------------------------------------93// ---------------------------------------------------------------------------
70// Page94// Page
71// ---------------------------------------------------------------------------95// ---------------------------------------------------------------------------
@@ -176,6 +200,22 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 104 unmodified lines
176 if (!trimmed) return;200 if (!trimmed) return;
177 setDetecting(true);201 setDetecting(true);
178 setError(null);202 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 }
179 // Detection is read-only — it inspects a URL and returns candidates without219 // Detection is read-only — it inspects a URL and returns candidates without
180 // mutating the catalog, so it invalidates nothing.220 // mutating the catalog, so it invalidates nothing.
181 const exit = await doDetect({221 const exit = await doDetect({
packages/react/src/routes/integrations.$namespace.tsx+16−1
@@ -1,10 +1,25 @@
1import { Schema } from "effect";
1import { createFileRoute } from "@tanstack/react-router";2import { createFileRoute } from "@tanstack/react-router";
2 3
3import { IntegrationDetailPage } from "../pages/integration-detail";4import { IntegrationDetailPage } from "../pages/integration-detail";
5import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs";
6
7const SearchParams = Schema.toStandardSchemaV1(
8 Schema.Struct({
9 tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])),
10 }),
11);
4 12
5export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({13export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({
14 validateSearch: SearchParams,
6 component: () => {15 component: () => {
7 const { namespace } = Route.useParams();16 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 );
9 },24 },
10});25});