Unified · Split
| @@ -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", |
| @@ -0,0 +1,2 @@ | |||
| 1 | 82609ee4ee2acbeb9056ee96a21b24bcee573aac | ||
| 2 | 0747e7274d47ee678e314020d3e1f670b57fb8fd | ||
Binary file not shown.
Binary file not shown.
| @@ -0,0 +1,377 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e fixture server and raw HTTP assertions */ | ||
| 2 | import { readFile } from "node:fs/promises"; | ||
| 3 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | ||
| 4 | import { join } from "node:path"; | ||
| 5 | |||
| 6 | import { expect } from "@effect/vitest"; | ||
| 7 | import { Effect } from "effect"; | ||
| 8 | |||
| 9 | import { scenario } from "../src/scenario"; | ||
| 10 | import { Api, Browser, Target } from "../src/services"; | ||
| 11 | import type { Identity, Target as TargetShape } from "../src/target"; | ||
| 12 | import type { BrowserSurface } from "../src/surfaces/browser"; | ||
| 13 | |||
| 14 | const textEncoder = new TextEncoder(); | ||
| 15 | |||
| 16 | const concat = (parts: readonly Uint8Array[]): Uint8Array => { | ||
| 17 | const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); | ||
| 18 | let offset = 0; | ||
| 19 | for (const part of parts) { | ||
| 20 | out.set(part, offset); | ||
| 21 | offset += part.length; | ||
| 22 | } | ||
| 23 | return out; | ||
| 24 | }; | ||
| 25 | |||
| 26 | const pktLine = (payload: string | Uint8Array): Uint8Array => { | ||
| 27 | const body = typeof payload === "string" ? textEncoder.encode(payload) : payload; | ||
| 28 | const header = textEncoder.encode((body.length + 4).toString(16).padStart(4, "0")); | ||
| 29 | return concat([header, body]); | ||
| 30 | }; | ||
| 31 | |||
| 32 | const FLUSH = textEncoder.encode("0000"); | ||
| 33 | |||
| 34 | const sideBand = (bytes: Uint8Array): Uint8Array => { | ||
| 35 | const chunks: Uint8Array[] = [pktLine("NAK\n")]; | ||
| 36 | for (let offset = 0; offset < bytes.length; offset += 60_000) { | ||
| 37 | const chunk = bytes.subarray(offset, offset + 60_000); | ||
| 38 | const payload = new Uint8Array(chunk.length + 1); | ||
| 39 | payload[0] = 1; | ||
| 40 | payload.set(chunk, 1); | ||
| 41 | chunks.push(pktLine(payload)); | ||
| 42 | } | ||
| 43 | chunks.push(FLUSH); | ||
| 44 | return concat(chunks); | ||
| 45 | }; | ||
| 46 | |||
| 47 | const advertisement = (sha: string): Uint8Array => | ||
| 48 | concat([ | ||
| 49 | pktLine("# service=git-upload-pack\n"), | ||
| 50 | FLUSH, | ||
| 51 | pktLine( | ||
| 52 | `${sha} HEAD\0symref=HEAD:refs/heads/main multi_ack side-band-64k thin-pack ofs-delta\n`, | ||
| 53 | ), | ||
| 54 | pktLine(`${sha} refs/heads/main\n`), | ||
| 55 | FLUSH, | ||
| 56 | ]); | ||
| 57 | |||
| 58 | const readFixture = async () => { | ||
| 59 | const dir = join(import.meta.dirname, "..", "fixtures", "custom-tools-git"); | ||
| 60 | const [shas, pack1, pack2] = await Promise.all([ | ||
| 61 | readFile(join(dir, "custom-tools-shas.txt"), "utf8"), | ||
| 62 | readFile(join(dir, "custom-tools-v1.pack")), | ||
| 63 | readFile(join(dir, "custom-tools-v2.pack")), | ||
| 64 | ]); | ||
| 65 | const [sha1, sha2] = shas.trim().split("\n"); | ||
| 66 | return { sha1: sha1!, sha2: sha2!, pack1, pack2 }; | ||
| 67 | }; | ||
| 68 | |||
| 69 | const fixtureGitServer = Effect.acquireRelease( | ||
| 70 | Effect.promise(async () => { | ||
| 71 | const fixture = await readFixture(); | ||
| 72 | let current = { sha: fixture.sha1, pack: new Uint8Array(fixture.pack1) }; | ||
| 73 | let packRequests = 0; | ||
| 74 | const server = createServer((request: IncomingMessage, response: ServerResponse) => { | ||
| 75 | if (request.url === "/repo.git/info/refs?service=git-upload-pack") { | ||
| 76 | response.writeHead(200, { | ||
| 77 | "content-type": "application/x-git-upload-pack-advertisement", | ||
| 78 | }); | ||
| 79 | response.end(advertisement(current.sha)); | ||
| 80 | return; | ||
| 81 | } | ||
| 82 | if (request.url === "/repo.git/git-upload-pack" && request.method === "POST") { | ||
| 83 | packRequests += 1; | ||
| 84 | response.writeHead(200, { "content-type": "application/x-git-upload-pack-result" }); | ||
| 85 | response.end(sideBand(current.pack)); | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | response.writeHead(404, { "content-type": "text/plain" }); | ||
| 89 | response.end("not found"); | ||
| 90 | }); | ||
| 91 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); | ||
| 92 | const address = server.address(); | ||
| 93 | if (!address || typeof address === "string") throw new Error("fixture server did not bind"); | ||
| 94 | return { | ||
| 95 | url: `http://127.0.0.1:${address.port}/repo.git`, | ||
| 96 | advance: () => { | ||
| 97 | current = { sha: fixture.sha2, pack: new Uint8Array(fixture.pack2) }; | ||
| 98 | }, | ||
| 99 | packRequests: () => packRequests, | ||
| 100 | close: () => new Promise<void>((resolve) => server.close(() => resolve())), | ||
| 101 | }; | ||
| 102 | }), | ||
| 103 | (server) => Effect.promise(() => server.close()).pipe(Effect.ignore), | ||
| 104 | ); | ||
| 105 | |||
| 106 | interface ToolRow { | ||
| 107 | readonly address: string; | ||
| 108 | readonly name: string; | ||
| 109 | readonly integration: string; | ||
| 110 | } | ||
| 111 | |||
| 112 | interface ExecuteResponse { | ||
| 113 | readonly status: "completed" | "paused"; | ||
| 114 | readonly text: string; | ||
| 115 | readonly structured: unknown; | ||
| 116 | readonly isError?: boolean; | ||
| 117 | } | ||
| 118 | |||
| 119 | const authHeaders = async ( | ||
| 120 | target: TargetShape, | ||
| 121 | identity: Identity, | ||
| 122 | ): Promise<Record<string, string>> => { | ||
| 123 | if (identity.headers) return identity.headers; | ||
| 124 | const credentials = identity.credentials; | ||
| 125 | if (!credentials) throw new Error(`identity ${identity.label} has no credentials`); | ||
| 126 | const response = await fetch(new URL("/api/auth/sign-in/email", target.baseUrl), { | ||
| 127 | method: "POST", | ||
| 128 | headers: { "content-type": "application/json", origin: new URL(target.baseUrl).origin }, | ||
| 129 | body: JSON.stringify(credentials), | ||
| 130 | redirect: "manual", | ||
| 131 | }); | ||
| 132 | const cookie = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; "); | ||
| 133 | if (!cookie) throw new Error(`sign-in set no cookie (${response.status})`); | ||
| 134 | return { cookie }; | ||
| 135 | }; | ||
| 136 | |||
| 137 | const request = async <T>( | ||
| 138 | target: TargetShape, | ||
| 139 | identity: Identity, | ||
| 140 | path: string, | ||
| 141 | init: RequestInit = {}, | ||
| 142 | expectedStatus = 200, | ||
| 143 | ): Promise<{ readonly body: T; readonly text: string }> => { | ||
| 144 | const headers = new Headers(init.headers); | ||
| 145 | headers.set("origin", new URL(target.baseUrl).origin); | ||
| 146 | for (const [name, value] of Object.entries(await authHeaders(target, identity))) { | ||
| 147 | headers.set(name, value); | ||
| 148 | } | ||
| 149 | if (init.body !== undefined && !headers.has("content-type")) { | ||
| 150 | headers.set("content-type", "application/json"); | ||
| 151 | } | ||
| 152 | const response = await fetch(new URL(path, target.baseUrl), { ...init, headers }); | ||
| 153 | const text = await response.text(); | ||
| 154 | expect(response.status, `${init.method ?? "GET"} ${path}: ${text}`).toBe(expectedStatus); | ||
| 155 | return { body: (text.length > 0 ? JSON.parse(text) : null) as T, text }; | ||
| 156 | }; | ||
| 157 | |||
| 158 | const postJson = <T>( | ||
| 159 | target: TargetShape, | ||
| 160 | identity: Identity, | ||
| 161 | path: string, | ||
| 162 | body?: unknown, | ||
| 163 | expectedStatus = 200, | ||
| 164 | ): Promise<{ readonly body: T; readonly text: string }> => | ||
| 165 | request<T>( | ||
| 166 | target, | ||
| 167 | identity, | ||
| 168 | path, | ||
| 169 | { | ||
| 170 | method: "POST", | ||
| 171 | ...(body !== undefined ? { body: JSON.stringify(body) } : {}), | ||
| 172 | }, | ||
| 173 | expectedStatus, | ||
| 174 | ); | ||
| 175 | |||
| 176 | const deletePath = ( | ||
| 177 | target: TargetShape, | ||
| 178 | identity: Identity, | ||
| 179 | path: string, | ||
| 180 | ): Promise<{ readonly body: { readonly removed?: boolean }; readonly text: string }> => | ||
| 181 | request(target, identity, path, { method: "DELETE" }); | ||
| 182 | |||
| 183 | const execute = (target: TargetShape, identity: Identity, code: string): Promise<ExecuteResponse> => | ||
| 184 | postJson<ExecuteResponse>(target, identity, "/api/executions", { | ||
| 185 | code, | ||
| 186 | autoApprove: true, | ||
| 187 | }).then((response) => response.body); | ||
| 188 | |||
| 189 | const addSourceThroughConsole = (input: { | ||
| 190 | readonly target: TargetShape; | ||
| 191 | readonly browser: BrowserSurface; | ||
| 192 | readonly identity: Identity; | ||
| 193 | readonly sourceUrl: string; | ||
| 194 | }) => | ||
| 195 | input.browser.session(input.identity, async ({ page, step }) => { | ||
| 196 | await step("Open the integrations page", async () => { | ||
| 197 | await page.goto(new URL("/integrations", input.target.baseUrl).toString(), { | ||
| 198 | waitUntil: "networkidle", | ||
| 199 | }); | ||
| 200 | }); | ||
| 201 | |||
| 202 | await step("Detect the Git repository as custom tools", async () => { | ||
| 203 | await page.getByRole("button", { name: "Connect", exact: true }).click(); | ||
| 204 | const dialog = page.getByRole("dialog"); | ||
| 205 | await dialog.getByRole("textbox").fill(input.sourceUrl); | ||
| 206 | await dialog.getByRole("button", { name: "Detect" }).click(); | ||
| 207 | await page.getByRole("heading", { name: "Add custom tools" }).waitFor(); | ||
| 208 | }); | ||
| 209 | |||
| 210 | await step("Sync the custom tools source", async () => { | ||
| 211 | await page.locator('input[type="password"]').fill("fixture-token"); | ||
| 212 | await page.getByRole("button", { name: "Sync source" }).click(); | ||
| 213 | await page.waitForURL(/\/integrations\/apps(?:\?|$)/, { timeout: 90_000 }); | ||
| 214 | await page.getByText("2 tools").waitFor({ timeout: 90_000 }); | ||
| 215 | }); | ||
| 216 | }); | ||
| 217 | |||
| 218 | const syncSourceInConsole = (input: { | ||
| 219 | readonly target: TargetShape; | ||
| 220 | readonly browser: BrowserSurface; | ||
| 221 | readonly identity: Identity; | ||
| 222 | readonly expectedNotice: string; | ||
| 223 | readonly expectedToolCount: string; | ||
| 224 | }) => | ||
| 225 | input.browser.session(input.identity, async ({ page, step }) => { | ||
| 226 | await step(`Sync custom tools and see ${input.expectedNotice}`, async () => { | ||
| 227 | await page.goto(new URL("/integrations/apps?tab=source", input.target.baseUrl).toString(), { | ||
| 228 | waitUntil: "networkidle", | ||
| 229 | }); | ||
| 230 | await page.getByRole("button", { name: "Sync" }).click(); | ||
| 231 | await page.getByText(input.expectedNotice).waitFor({ timeout: 90_000 }); | ||
| 232 | await page | ||
| 233 | .locator("p") | ||
| 234 | .filter({ hasText: new RegExp(`^${input.expectedToolCount}$`) }) | ||
| 235 | .waitFor({ timeout: 90_000 }); | ||
| 236 | }); | ||
| 237 | }); | ||
| 238 | |||
| 239 | const removeSourceThroughConsole = (input: { | ||
| 240 | readonly target: TargetShape; | ||
| 241 | readonly browser: BrowserSurface; | ||
| 242 | readonly identity: Identity; | ||
| 243 | }) => | ||
| 244 | input.browser.session(input.identity, async ({ page, step }) => { | ||
| 245 | await step("Remove the custom tools source", async () => { | ||
| 246 | await page.goto(new URL("/integrations/apps?tab=source", input.target.baseUrl).toString(), { | ||
| 247 | waitUntil: "networkidle", | ||
| 248 | }); | ||
| 249 | await page.getByRole("button", { name: "Remove" }).click(); | ||
| 250 | await page.getByRole("button", { name: "Remove source" }).click(); | ||
| 251 | await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 }); | ||
| 252 | }); | ||
| 253 | }); | ||
| 254 | |||
| 255 | scenario( | ||
| 256 | "Custom tools · Git source syncs, invokes, refreshes, and removes", | ||
| 257 | { timeout: 300_000 }, | ||
| 258 | Effect.scoped( | ||
| 259 | Effect.gen(function* () { | ||
| 260 | const target = yield* Target; | ||
| 261 | if (target.name !== "selfhost") return; | ||
| 262 | const browser = yield* Browser; | ||
| 263 | yield* Api; | ||
| 264 | const identity = yield* target.newIdentity(); | ||
| 265 | const git = yield* fixtureGitServer; | ||
| 266 | |||
| 267 | yield* Effect.ensuring( | ||
| 268 | Effect.gen(function* () { | ||
| 269 | const unauthorized = yield* Effect.promise(() => | ||
| 270 | fetch(new URL("/api/apps/sources/custom-tools/sync", target.baseUrl), { | ||
| 271 | method: "POST", | ||
| 272 | }), | ||
| 273 | ); | ||
| 274 | expect(unauthorized.status, "sync requires authentication").toBe(401); | ||
| 275 | yield* Effect.promise(() => unauthorized.text()); | ||
| 276 | |||
| 277 | yield* addSourceThroughConsole({ target, browser, identity, sourceUrl: git.url }); | ||
| 278 | |||
| 279 | const sources = yield* Effect.promise(() => | ||
| 280 | request<{ | ||
| 281 | readonly sources: readonly { | ||
| 282 | readonly slug: string; | ||
| 283 | readonly app: string; | ||
| 284 | readonly config: { readonly kind: string; readonly url?: string }; | ||
| 285 | }[]; | ||
| 286 | }>(target, identity, "/api/apps/sources"), | ||
| 287 | ); | ||
| 288 | expect(sources.text).not.toContain("fixture-token"); | ||
| 289 | const source = sources.body.sources.find((item) => item.app === "repo"); | ||
| 290 | expect(source).toMatchObject({ | ||
| 291 | app: "repo", | ||
| 292 | config: { kind: "git", url: git.url }, | ||
| 293 | }); | ||
| 294 | |||
| 295 | const tools = yield* Effect.promise(() => | ||
| 296 | request<readonly ToolRow[]>(target, identity, "/api/tools?integration=apps"), | ||
| 297 | ); | ||
| 298 | expect(tools.body.map((tool) => tool.name).sort()).toEqual(["echo-tool", "static-tool"]); | ||
| 299 | const echoTool = tools.body.find((tool) => tool.name === "echo-tool"); | ||
| 300 | expect(echoTool).toBeDefined(); | ||
| 301 | |||
| 302 | const schema = yield* Effect.promise(() => | ||
| 303 | request<{ | ||
| 304 | readonly inputSchema: { | ||
| 305 | readonly properties?: Record< | ||
| 306 | string, | ||
| 307 | { readonly enum?: readonly string[]; readonly default?: unknown } | ||
| 308 | >; | ||
| 309 | readonly required?: readonly string[]; | ||
| 310 | }; | ||
| 311 | }>( | ||
| 312 | target, | ||
| 313 | identity, | ||
| 314 | `/api/tools/schema?address=${encodeURIComponent(echoTool!.address)}`, | ||
| 315 | ), | ||
| 316 | ); | ||
| 317 | expect(schema.body.inputSchema.properties?.apps).toMatchObject({ | ||
| 318 | enum: ["tools.apps.org.published"], | ||
| 319 | default: "tools.apps.org.published", | ||
| 320 | }); | ||
| 321 | expect(schema.body.inputSchema.required ?? []).not.toContain("apps"); | ||
| 322 | |||
| 323 | const invoked = yield* Effect.promise(() => | ||
| 324 | execute( | ||
| 325 | target, | ||
| 326 | identity, | ||
| 327 | `return await tools["apps.org.published.echo-tool"]({ message: "hello", apps: "tools.apps.org.published" });`, | ||
| 328 | ), | ||
| 329 | ); | ||
| 330 | expect(invoked.status, invoked.text).toBe("completed"); | ||
| 331 | expect(invoked.isError, invoked.text).toBe(false); | ||
| 332 | expect(JSON.stringify(invoked.structured)).toContain("hello"); | ||
| 333 | expect(JSON.stringify(invoked.structured)).toContain("v1"); | ||
| 334 | |||
| 335 | expect(git.packRequests(), "initial publish fetched one pack").toBe(1); | ||
| 336 | git.advance(); | ||
| 337 | yield* syncSourceInConsole({ | ||
| 338 | target, | ||
| 339 | browser, | ||
| 340 | identity, | ||
| 341 | expectedNotice: "Added: extra-tool", | ||
| 342 | expectedToolCount: "3 tools", | ||
| 343 | }); | ||
| 344 | |||
| 345 | yield* syncSourceInConsole({ | ||
| 346 | target, | ||
| 347 | browser, | ||
| 348 | identity, | ||
| 349 | expectedNotice: "Already up to date.", | ||
| 350 | expectedToolCount: "3 tools", | ||
| 351 | }); | ||
| 352 | |||
| 353 | yield* removeSourceThroughConsole({ target, browser, identity }); | ||
| 354 | |||
| 355 | const afterRemove = yield* Effect.promise(() => | ||
| 356 | request<readonly ToolRow[]>(target, identity, "/api/tools?integration=apps"), | ||
| 357 | ); | ||
| 358 | expect(afterRemove.body).toEqual([]); | ||
| 359 | }), | ||
| 360 | Effect.promise(async () => { | ||
| 361 | const sources = await request<{ | ||
| 362 | readonly sources: readonly { readonly slug: string; readonly app: string }[]; | ||
| 363 | }>(target, identity, "/api/apps/sources").catch(() => ({ body: { sources: [] } })); | ||
| 364 | for (const source of sources.body.sources) { | ||
| 365 | if (source.app === "repo") { | ||
| 366 | await deletePath( | ||
| 367 | target, | ||
| 368 | identity, | ||
| 369 | `/api/apps/sources/${encodeURIComponent(source.slug)}`, | ||
| 370 | ).catch(() => undefined); | ||
| 371 | } | ||
| 372 | } | ||
| 373 | }).pipe(Effect.ignore), | ||
| 374 | ); | ||
| 375 | }), | ||
| 376 | ), | ||
| 377 | ); | ||
| @@ -362,13 +362,14 @@ describe("connections.refresh", () => { · 361 unmodified lines | |||
| 362 | 362 | ||
| 363 | describe("tool catalog sync safety", () => { | 363 | describe("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* () { |
| @@ -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 = |
| @@ -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 | } |
| @@ -136,12 +136,18 @@ export const isPrivateGitHost = (host: string): boolean => { · 135 unmodified lines | |||
| 136 | 136 | ||
| 137 | export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => { | 137 | export 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; |
| @@ -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); |
| @@ -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 | ); |
| @@ -207,13 +207,7 @@ const activeToolNamesFor = ( · 206 unmodified lines | |||
| 207 | ): Effect.Effect<readonly string[], unknown> => | 207 | ): Effect.Effect<readonly string[], unknown> => |
| 208 | ctx.storage | 208 | 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 | ||
| 218 | const makeAppsExtension = ( | 212 | const 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 | ||
| 555 | export const appsPlugin = makeAppsPlugin(); | 577 | export const appsPlugin = makeAppsPlugin(); |
| @@ -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 | ||
| @@ -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 | StorageFailure | 108 | 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: "" }, |
| @@ -0,0 +1,264 @@ | |||
| 1 | import { useMemo, useState } from "react"; | ||
| 2 | import { Effect, Exit } from "effect"; | ||
| 3 | |||
| 4 | import { Button } from "@executor-js/react/components/button"; | ||
| 5 | import { | ||
| 6 | CardStack, | ||
| 7 | CardStackContent, | ||
| 8 | CardStackEntryField, | ||
| 9 | } from "@executor-js/react/components/card-stack"; | ||
| 10 | import { FloatActions } from "@executor-js/react/components/float-actions"; | ||
| 11 | import { Input } from "@executor-js/react/components/input"; | ||
| 12 | import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | APPS_INTEGRATION_SLUG, | ||
| 16 | createCustomToolSourceEffect, | ||
| 17 | formatSyncErrors, | ||
| 18 | parseGitSourceUrl, | ||
| 19 | slugifyCustomToolsAppName, | ||
| 20 | suggestCustomToolsAppName, | ||
| 21 | syncCustomToolSourceEffect, | ||
| 22 | validateCustomToolsAppSlug, | ||
| 23 | validateGitSourceUrl, | ||
| 24 | type AppSourceKind, | ||
| 25 | } from "./custom-tools-client"; | ||
| 26 | |||
| 27 | export default function AddCustomToolsSource(props: { | ||
| 28 | readonly onComplete: (slug?: string) => void; | ||
| 29 | readonly onCancel: () => void; | ||
| 30 | readonly initialUrl?: string; | ||
| 31 | readonly initialNamespace?: string; | ||
| 32 | readonly sourceKinds?: readonly AppSourceKind[]; | ||
| 33 | }) { | ||
| 34 | const sourceKinds = props.sourceKinds ?? ["git"]; | ||
| 35 | const allowLocalDirectory = sourceKinds.includes("local-directory"); | ||
| 36 | const initialKind = | ||
| 37 | props.initialUrl && props.initialUrl.startsWith("/") && allowLocalDirectory | ||
| 38 | ? "local-directory" | ||
| 39 | : "git"; | ||
| 40 | const [kind, setKind] = useState<AppSourceKind>(initialKind); | ||
| 41 | const [url, setUrl] = useState(props.initialUrl ?? ""); | ||
| 42 | const [path, setPath] = useState( | ||
| 43 | initialKind === "local-directory" ? (props.initialUrl ?? "") : "", | ||
| 44 | ); | ||
| 45 | const [ref, setRef] = useState(""); | ||
| 46 | const [name, setName] = useState( | ||
| 47 | props.initialNamespace ? slugifyCustomToolsAppName(props.initialNamespace) : "", | ||
| 48 | ); | ||
| 49 | const [nameTouched, setNameTouched] = useState(props.initialNamespace !== undefined); | ||
| 50 | const [token, setToken] = useState(""); | ||
| 51 | const [fieldError, setFieldError] = useState<string | null>(null); | ||
| 52 | const [nameError, setNameError] = useState<string | null>(null); | ||
| 53 | const [syncing, setSyncing] = useState(false); | ||
| 54 | const [syncError, setSyncError] = useState<string | null>(null); | ||
| 55 | |||
| 56 | const sourceValue = kind === "git" ? url : path; | ||
| 57 | const effectiveName = useMemo( | ||
| 58 | () => (nameTouched ? name : slugifyCustomToolsAppName(suggestCustomToolsAppName(sourceValue))), | ||
| 59 | [name, nameTouched, sourceValue], | ||
| 60 | ); | ||
| 61 | const slug = effectiveName; | ||
| 62 | const slugAlreadyExists = useSlugAlreadyExists(slug); | ||
| 63 | |||
| 64 | const submit = async () => { | ||
| 65 | const nextFieldError = | ||
| 66 | kind === "git" | ||
| 67 | ? validateGitSourceUrl(url) | ||
| 68 | : path.trim().startsWith("/") | ||
| 69 | ? null | ||
| 70 | : "Enter an absolute directory path."; | ||
| 71 | const nextNameError = validateCustomToolsAppSlug(slug); | ||
| 72 | setFieldError(nextFieldError); | ||
| 73 | setNameError(nextNameError); | ||
| 74 | setSyncError(null); | ||
| 75 | if (nextFieldError || nextNameError) return; | ||
| 76 | if (slugAlreadyExists) { | ||
| 77 | setSyncError(`An integration named "${slug}" already exists. Choose another source name.`); | ||
| 78 | return; | ||
| 79 | } | ||
| 80 | |||
| 81 | setSyncing(true); | ||
| 82 | const parsedGitUrl = kind === "git" ? parseGitSourceUrl(url) : null; | ||
| 83 | const createExit = await Effect.runPromiseExit( | ||
| 84 | createCustomToolSourceEffect( | ||
| 85 | kind === "git" | ||
| 86 | ? { | ||
| 87 | kind: "git", | ||
| 88 | slug, | ||
| 89 | app: slug, | ||
| 90 | url: parsedGitUrl?.ok ? parsedGitUrl.url : url.trim(), | ||
| 91 | ...(ref.trim() ? { ref: ref.trim() } : {}), | ||
| 92 | ...(token.trim() ? { token: token.trim() } : {}), | ||
| 93 | } | ||
| 94 | : { kind: "local-directory", slug, app: slug, path: path.trim() }, | ||
| 95 | ), | ||
| 96 | ); | ||
| 97 | if (Exit.isFailure(createExit)) { | ||
| 98 | setSyncError("Failed to create custom tools source."); | ||
| 99 | setSyncing(false); | ||
| 100 | return; | ||
| 101 | } | ||
| 102 | const syncExit = await Effect.runPromiseExit( | ||
| 103 | syncCustomToolSourceEffect(createExit.value.source.slug), | ||
| 104 | ); | ||
| 105 | if (Exit.isFailure(syncExit)) { | ||
| 106 | setSyncError("Failed to sync custom tools source."); | ||
| 107 | setSyncing(false); | ||
| 108 | return; | ||
| 109 | } | ||
| 110 | const result = syncExit.value; | ||
| 111 | if (result.status === "failed") { | ||
| 112 | setSyncError(formatSyncErrors(result).join("\n") || "Sync failed."); | ||
| 113 | setSyncing(false); | ||
| 114 | return; | ||
| 115 | } | ||
| 116 | setSyncing(false); | ||
| 117 | props.onComplete(APPS_INTEGRATION_SLUG); | ||
| 118 | }; | ||
| 119 | |||
| 120 | return ( | ||
| 121 | <div className="flex flex-1 flex-col gap-6"> | ||
| 122 | <div> | ||
| 123 | <h1 className="text-xl font-semibold text-foreground">Add custom tools</h1> | ||
| 124 | <p className="mt-1 text-[13px] text-muted-foreground"> | ||
| 125 | Sync tools from a Git repository, then publish them to the Apps catalog. | ||
| 126 | </p> | ||
| 127 | </div> | ||
| 128 | |||
| 129 | <CardStack> | ||
| 130 | <CardStackContent className="border-t-0"> | ||
| 131 | {allowLocalDirectory && ( | ||
| 132 | <CardStackEntryField | ||
| 133 | label="Source type" | ||
| 134 | description="- Git is portable. Local directories are self-host only." | ||
| 135 | > | ||
| 136 | <div className="flex gap-2"> | ||
| 137 | <Button | ||
| 138 | type="button" | ||
| 139 | variant={kind === "git" ? "default" : "outline"} | ||
| 140 | onClick={() => setKind("git")} | ||
| 141 | disabled={syncing} | ||
| 142 | > | ||
| 143 | Git repository | ||
| 144 | </Button> | ||
| 145 | <Button | ||
| 146 | type="button" | ||
| 147 | variant={kind === "local-directory" ? "default" : "outline"} | ||
| 148 | onClick={() => setKind("local-directory")} | ||
| 149 | disabled={syncing} | ||
| 150 | > | ||
| 151 | Directory path | ||
| 152 | </Button> | ||
| 153 | </div> | ||
| 154 | </CardStackEntryField> | ||
| 155 | )} | ||
| 156 | |||
| 157 | {kind === "git" ? ( | ||
| 158 | <> | ||
| 159 | <CardStackEntryField label="Git repository" description="- Any https Git remote."> | ||
| 160 | <div className="space-y-1.5"> | ||
| 161 | <Input | ||
| 162 | value={url} | ||
| 163 | onChange={(event) => { | ||
| 164 | setUrl((event.target as HTMLInputElement).value); | ||
| 165 | setFieldError(null); | ||
| 166 | setSyncError(null); | ||
| 167 | }} | ||
| 168 | onBlur={() => setFieldError(validateGitSourceUrl(url))} | ||
| 169 | placeholder="https://github.com/acme/tools.git" | ||
| 170 | className="font-mono text-sm" | ||
| 171 | aria-invalid={fieldError ? true : undefined} | ||
| 172 | /> | ||
| 173 | {fieldError && <p className="text-xs text-destructive">{fieldError}</p>} | ||
| 174 | </div> | ||
| 175 | </CardStackEntryField> | ||
| 176 | <CardStackEntryField | ||
| 177 | label="Ref (optional)" | ||
| 178 | description="- Branch, tag, or commit SHA." | ||
| 179 | > | ||
| 180 | <Input | ||
| 181 | value={ref} | ||
| 182 | onChange={(event) => setRef((event.target as HTMLInputElement).value)} | ||
| 183 | placeholder="main" | ||
| 184 | className="font-mono text-sm" | ||
| 185 | disabled={syncing} | ||
| 186 | /> | ||
| 187 | </CardStackEntryField> | ||
| 188 | <CardStackEntryField | ||
| 189 | label="Token (optional)" | ||
| 190 | description="- For private repositories. Never shown after saving." | ||
| 191 | > | ||
| 192 | <Input | ||
| 193 | value={token} | ||
| 194 | type="password" | ||
| 195 | onChange={(event) => setToken((event.target as HTMLInputElement).value)} | ||
| 196 | autoComplete="off" | ||
| 197 | className="font-mono text-sm" | ||
| 198 | disabled={syncing} | ||
| 199 | /> | ||
| 200 | </CardStackEntryField> | ||
| 201 | </> | ||
| 202 | ) : ( | ||
| 203 | <CardStackEntryField | ||
| 204 | label="Directory path" | ||
| 205 | description="- Absolute path on the self-host server." | ||
| 206 | > | ||
| 207 | <div className="space-y-1.5"> | ||
| 208 | <Input | ||
| 209 | value={path} | ||
| 210 | onChange={(event) => { | ||
| 211 | setPath((event.target as HTMLInputElement).value); | ||
| 212 | setFieldError(null); | ||
| 213 | setSyncError(null); | ||
| 214 | }} | ||
| 215 | placeholder="/srv/executor-tools" | ||
| 216 | className="font-mono text-sm" | ||
| 217 | aria-invalid={fieldError ? true : undefined} | ||
| 218 | /> | ||
| 219 | {fieldError && <p className="text-xs text-destructive">{fieldError}</p>} | ||
| 220 | </div> | ||
| 221 | </CardStackEntryField> | ||
| 222 | )} | ||
| 223 | |||
| 224 | <CardStackEntryField | ||
| 225 | label="Source name" | ||
| 226 | description="- Lowercase letters, numbers, and hyphens." | ||
| 227 | > | ||
| 228 | <div className="space-y-1.5"> | ||
| 229 | <Input | ||
| 230 | value={effectiveName} | ||
| 231 | onChange={(event) => { | ||
| 232 | setNameTouched(true); | ||
| 233 | setName(slugifyCustomToolsAppName((event.target as HTMLInputElement).value)); | ||
| 234 | setNameError(null); | ||
| 235 | setSyncError(null); | ||
| 236 | }} | ||
| 237 | placeholder="custom-tools" | ||
| 238 | className="text-sm" | ||
| 239 | aria-invalid={nameError ? true : undefined} | ||
| 240 | /> | ||
| 241 | {nameError && <p className="text-xs text-destructive">{nameError}</p>} | ||
| 242 | {slugAlreadyExists && !syncing && !nameError && ( | ||
| 243 | <p className="text-xs text-destructive"> | ||
| 244 | An integration named "{slug}" 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 | } | ||
| @@ -0,0 +1,287 @@ | |||
| 1 | import { useEffect, useState } from "react"; | ||
| 2 | import { Effect, Exit } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | AlertDialog, | ||
| 6 | AlertDialogAction, | ||
| 7 | AlertDialogCancel, | ||
| 8 | AlertDialogContent, | ||
| 9 | AlertDialogDescription, | ||
| 10 | AlertDialogFooter, | ||
| 11 | AlertDialogHeader, | ||
| 12 | AlertDialogTitle, | ||
| 13 | AlertDialogTrigger, | ||
| 14 | } from "@executor-js/react/components/alert-dialog"; | ||
| 15 | import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; | ||
| 16 | import { Button } from "@executor-js/react/components/button"; | ||
| 17 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | listCustomToolSourcesEffect, | ||
| 21 | removeCustomToolSourceEffect, | ||
| 22 | syncCustomToolSourceEffect, | ||
| 23 | type AppSourceRecord, | ||
| 24 | } from "./custom-tools-client"; | ||
| 25 | import { | ||
| 26 | sourceFailureLines, | ||
| 27 | sourcePanelModel, | ||
| 28 | syncNoticeFromResult, | ||
| 29 | toolsCountLabel, | ||
| 30 | type SyncNoticeModel, | ||
| 31 | } from "./source-panel-model"; | ||
| 32 | |||
| 33 | const integrationsHref = (): string => { | ||
| 34 | const match = window.location.pathname.match(/^(.*)\/integrations(?:\/.*)?$/); | ||
| 35 | return `${match?.[1] ?? ""}/integrations`; | ||
| 36 | }; | ||
| 37 | |||
| 38 | type LoadState = | ||
| 39 | | { readonly status: "loading" } | ||
| 40 | | { readonly status: "error"; readonly message: string } | ||
| 41 | | { readonly status: "ready"; readonly sources: readonly AppSourceRecord[] }; | ||
| 42 | |||
| 43 | export default function CustomToolsAccountsPanel() { | ||
| 44 | const [loadState, setLoadState] = useState<LoadState>({ status: "loading" }); | ||
| 45 | const [syncingSlug, setSyncingSlug] = useState<string | null>(null); | ||
| 46 | const [removingSlug, setRemovingSlug] = useState<string | null>(null); | ||
| 47 | const [notice, setNotice] = useState<Record<string, SyncNoticeModel | undefined>>({}); | ||
| 48 | const [removeError, setRemoveError] = useState<string | null>(null); | ||
| 49 | |||
| 50 | const loadSources = async () => { | ||
| 51 | setLoadState({ status: "loading" }); | ||
| 52 | const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect()); | ||
| 53 | if (Exit.isFailure(exit)) { | ||
| 54 | setLoadState({ status: "error", message: "Failed to load custom tools sources." }); | ||
| 55 | return; | ||
| 56 | } | ||
| 57 | setLoadState({ status: "ready", sources: exit.value.sources }); | ||
| 58 | }; | ||
| 59 | |||
| 60 | useEffect(() => { | ||
| 61 | let active = true; | ||
| 62 | void (async () => { | ||
| 63 | const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect()); | ||
| 64 | if (!active) return; | ||
| 65 | if (Exit.isFailure(exit)) { | ||
| 66 | setLoadState({ status: "error", message: "Failed to load custom tools sources." }); | ||
| 67 | return; | ||
| 68 | } | ||
| 69 | setLoadState({ status: "ready", sources: exit.value.sources }); | ||
| 70 | })(); | ||
| 71 | return () => { | ||
| 72 | active = false; | ||
| 73 | }; | ||
| 74 | }, []); | ||
| 75 | |||
| 76 | const syncSource = async (source: AppSourceRecord) => { | ||
| 77 | setSyncingSlug(source.slug); | ||
| 78 | setNotice((current) => ({ ...current, [source.slug]: undefined })); | ||
| 79 | const beforeTools = | ||
| 80 | source.status.type === "published" || source.status.type === "up-to-date" | ||
| 81 | ? source.status.tools | ||
| 82 | : []; | ||
| 83 | const exit = await Effect.runPromiseExit(syncCustomToolSourceEffect(source.slug)); | ||
| 84 | if (Exit.isFailure(exit)) { | ||
| 85 | setNotice((current) => ({ | ||
| 86 | ...current, | ||
| 87 | [source.slug]: { | ||
| 88 | status: "failed", | ||
| 89 | message: "Sync failed.", | ||
| 90 | added: [], | ||
| 91 | removed: [], | ||
| 92 | errors: ["Failed to sync custom tools source."], | ||
| 93 | }, | ||
| 94 | })); | ||
| 95 | setSyncingSlug(null); | ||
| 96 | return; | ||
| 97 | } | ||
| 98 | setNotice((current) => ({ | ||
| 99 | ...current, | ||
| 100 | [source.slug]: syncNoticeFromResult(exit.value, beforeTools), | ||
| 101 | })); | ||
| 102 | await loadSources(); | ||
| 103 | setSyncingSlug(null); | ||
| 104 | }; | ||
| 105 | |||
| 106 | const removeSource = async (source: AppSourceRecord) => { | ||
| 107 | setRemovingSlug(source.slug); | ||
| 108 | setRemoveError(null); | ||
| 109 | const exit = await Effect.runPromiseExit(removeCustomToolSourceEffect(source.slug)); | ||
| 110 | if (Exit.isFailure(exit)) { | ||
| 111 | setRemoveError("Failed to remove custom tools source."); | ||
| 112 | setRemovingSlug(null); | ||
| 113 | return; | ||
| 114 | } | ||
| 115 | await loadSources(); | ||
| 116 | setRemovingSlug(null); | ||
| 117 | window.location.assign(integrationsHref()); | ||
| 118 | }; | ||
| 119 | |||
| 120 | return ( | ||
| 121 | <div className="mx-auto max-w-3xl space-y-6 px-6 py-8"> | ||
| 122 | {loadState.status === "loading" && ( | ||
| 123 | <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground"> | ||
| 124 | Loading sources... | ||
| 125 | </div> | ||
| 126 | )} | ||
| 127 | |||
| 128 | {loadState.status === "error" && ( | ||
| 129 | <Alert variant="destructive"> | ||
| 130 | <AlertTitle>Failed to load sources</AlertTitle> | ||
| 131 | <AlertDescription> | ||
| 132 | <div className="space-y-3"> | ||
| 133 | <p>{loadState.message}</p> | ||
| 134 | <Button type="button" size="sm" variant="outline" onClick={() => void loadSources()}> | ||
| 135 | Retry | ||
| 136 | </Button> | ||
| 137 | </div> | ||
| 138 | </AlertDescription> | ||
| 139 | </Alert> | ||
| 140 | )} | ||
| 141 | |||
| 142 | {loadState.status === "ready" && loadState.sources.length === 0 && ( | ||
| 143 | <div className="rounded-lg border border-border p-4"> | ||
| 144 | <p className="text-sm font-medium text-foreground">No custom tool sources</p> | ||
| 145 | <p className="mt-1 text-sm text-muted-foreground"> | ||
| 146 | Add a Git repository to publish tools into the Apps catalog. | ||
| 147 | </p> | ||
| 148 | </div> | ||
| 149 | )} | ||
| 150 | |||
| 151 | {loadState.status === "ready" && | ||
| 152 | loadState.sources.map((source) => ( | ||
| 153 | <SourceDetail | ||
| 154 | key={source.slug} | ||
| 155 | source={source} | ||
| 156 | notice={notice[source.slug] ?? null} | ||
| 157 | removeError={removeError} | ||
| 158 | syncing={syncingSlug === source.slug} | ||
| 159 | removing={removingSlug === source.slug} | ||
| 160 | onSync={() => void syncSource(source)} | ||
| 161 | onRemove={() => void removeSource(source)} | ||
| 162 | /> | ||
| 163 | ))} | ||
| 164 | </div> | ||
| 165 | ); | ||
| 166 | } | ||
| 167 | |||
| 168 | function SourceDetail(props: { | ||
| 169 | readonly source: AppSourceRecord; | ||
| 170 | readonly notice: SyncNoticeModel | null; | ||
| 171 | readonly removeError: string | null; | ||
| 172 | readonly syncing: boolean; | ||
| 173 | readonly removing: boolean; | ||
| 174 | readonly onSync: () => void; | ||
| 175 | readonly onRemove: () => void; | ||
| 176 | }) { | ||
| 177 | const { source, notice } = props; | ||
| 178 | const model = sourcePanelModel(source); | ||
| 179 | const failureLines = sourceFailureLines(source); | ||
| 180 | const noticeHasDetails = | ||
| 181 | notice !== null && | ||
| 182 | (notice.added.length > 0 || | ||
| 183 | notice.removed.length > 0 || | ||
| 184 | notice.errors.length > 0 || | ||
| 185 | notice.sourceRef !== undefined); | ||
| 186 | return ( | ||
| 187 | <div className="min-w-0 rounded-lg border border-border bg-card"> | ||
| 188 | <div className="flex items-start justify-between gap-4 border-b border-border p-4"> | ||
| 189 | <div className="min-w-0"> | ||
| 190 | <h3 className="truncate text-sm font-medium text-foreground">{model.title}</h3> | ||
| 191 | <p className="mt-1 truncate text-xs text-muted-foreground">{model.source}</p> | ||
| 192 | </div> | ||
| 193 | <div className="flex shrink-0 gap-2"> | ||
| 194 | <Button type="button" size="sm" onClick={props.onSync} loading={props.syncing}> | ||
| 195 | Sync | ||
| 196 | </Button> | ||
| 197 | <AlertDialog> | ||
| 198 | <AlertDialogTrigger asChild> | ||
| 199 | <Button | ||
| 200 | type="button" | ||
| 201 | size="sm" | ||
| 202 | variant="outline" | ||
| 203 | className="border-destructive/30 text-destructive hover:bg-destructive/10" | ||
| 204 | disabled={props.removing} | ||
| 205 | > | ||
| 206 | Remove | ||
| 207 | </Button> | ||
| 208 | </AlertDialogTrigger> | ||
| 209 | <AlertDialogContent size="sm"> | ||
| 210 | <AlertDialogHeader> | ||
| 211 | <AlertDialogTitle>Remove {source.app}?</AlertDialogTitle> | ||
| 212 | <AlertDialogDescription> | ||
| 213 | This removes {toolsCountLabel(model.tools.length)} from the catalog. The source is | ||
| 214 | untouched and can be added again later. | ||
| 215 | </AlertDialogDescription> | ||
| 216 | </AlertDialogHeader> | ||
| 217 | <AlertDialogFooter> | ||
| 218 | <AlertDialogCancel>Cancel</AlertDialogCancel> | ||
| 219 | <AlertDialogAction variant="destructive" onClick={props.onRemove}> | ||
| 220 | {props.removing ? "Removing..." : "Remove source"} | ||
| 221 | </AlertDialogAction> | ||
| 222 | </AlertDialogFooter> | ||
| 223 | </AlertDialogContent> | ||
| 224 | </AlertDialog> | ||
| 225 | </div> | ||
| 226 | </div> | ||
| 227 | |||
| 228 | <div className="space-y-5 p-4"> | ||
| 229 | <div className="grid gap-3 text-sm sm:grid-cols-3"> | ||
| 230 | <Meta label="Status" value={model.status} /> | ||
| 231 | <Meta label="Source ref" value={model.sourceRef} mono /> | ||
| 232 | <Meta label="Tools" value={toolsCountLabel(model.tools.length)} /> | ||
| 233 | </div> | ||
| 234 | |||
| 235 | {failureLines.length > 0 && ( | ||
| 236 | <Alert variant="destructive"> | ||
| 237 | <AlertTitle>Last sync failed</AlertTitle> | ||
| 238 | <AlertDescription> | ||
| 239 | <div className="space-y-1"> | ||
| 240 | {failureLines.map((line) => ( | ||
| 241 | <p key={line}>{line}</p> | ||
| 242 | ))} | ||
| 243 | </div> | ||
| 244 | </AlertDescription> | ||
| 245 | </Alert> | ||
| 246 | )} | ||
| 247 | |||
| 248 | {notice && ( | ||
| 249 | <Alert variant={notice.status === "failed" ? "destructive" : "default"}> | ||
| 250 | <AlertTitle>{notice.message}</AlertTitle> | ||
| 251 | {noticeHasDetails && ( | ||
| 252 | <AlertDescription> | ||
| 253 | <div className="space-y-1"> | ||
| 254 | {notice.added.length > 0 && <p>Added: {notice.added.join(", ")}</p>} | ||
| 255 | {notice.removed.length > 0 && <p>Removed: {notice.removed.join(", ")}</p>} | ||
| 256 | {notice.errors.map((error) => ( | ||
| 257 | <p key={error}>{error}</p> | ||
| 258 | ))} | ||
| 259 | {notice.sourceRef && ( | ||
| 260 | <p className="font-mono text-xs text-muted-foreground"> | ||
| 261 | Commit {notice.sourceRef} | ||
| 262 | </p> | ||
| 263 | )} | ||
| 264 | </div> | ||
| 265 | </AlertDescription> | ||
| 266 | )} | ||
| 267 | </Alert> | ||
| 268 | )} | ||
| 269 | |||
| 270 | {props.removeError && <FormErrorAlert message={props.removeError} />} | ||
| 271 | </div> | ||
| 272 | </div> | ||
| 273 | ); | ||
| 274 | } | ||
| 275 | |||
| 276 | function Meta(props: { readonly label: string; readonly value: string; readonly mono?: boolean }) { | ||
| 277 | return ( | ||
| 278 | <div className="min-w-0"> | ||
| 279 | <p className="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground"> | ||
| 280 | {props.label} | ||
| 281 | </p> | ||
| 282 | <p className={props.mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate text-sm"}> | ||
| 283 | {props.value} | ||
| 284 | </p> | ||
| 285 | </div> | ||
| 286 | ); | ||
| 287 | } | ||
| @@ -0,0 +1,311 @@ | |||
| 1 | import { Data, Effect, Schema } from "effect"; | ||
| 2 | |||
| 3 | export const CUSTOM_TOOLS_PLUGIN_KEY = "apps"; | ||
| 4 | export const CUSTOM_TOOLS_LABEL = "Custom tools"; | ||
| 5 | export const APPS_INTEGRATION_SLUG = "apps"; | ||
| 6 | |||
| 7 | export type AppSourceKind = "git" | "local-directory"; | ||
| 8 | |||
| 9 | export interface SyncDiagnostic { | ||
| 10 | readonly stage: "source" | "discover" | "bundle" | "collect" | "project"; | ||
| 11 | readonly message: string; | ||
| 12 | readonly diagnostics?: readonly { readonly path: string; readonly message: string }[]; | ||
| 13 | } | ||
| 14 | |||
| 15 | export type SourceStatus = | ||
| 16 | | { readonly type: "pending" } | ||
| 17 | | { | ||
| 18 | readonly type: "published" | "up-to-date"; | ||
| 19 | readonly at: number; | ||
| 20 | readonly tools: readonly string[]; | ||
| 21 | } | ||
| 22 | | { readonly type: "failed"; readonly at: number; readonly errors: readonly SyncDiagnostic[] }; | ||
| 23 | |||
| 24 | export type AppSourceRecord = | ||
| 25 | | { | ||
| 26 | readonly slug: string; | ||
| 27 | readonly app: string; | ||
| 28 | readonly kind: "git"; | ||
| 29 | readonly config: { | ||
| 30 | readonly kind: "git"; | ||
| 31 | readonly url: string; | ||
| 32 | readonly ref?: string; | ||
| 33 | readonly tokenProvider?: string; | ||
| 34 | readonly tokenItemId?: string; | ||
| 35 | }; | ||
| 36 | readonly sourceRef?: string; | ||
| 37 | readonly description?: string; | ||
| 38 | readonly status: SourceStatus; | ||
| 39 | readonly updatedAt: number; | ||
| 40 | } | ||
| 41 | | { | ||
| 42 | readonly slug: string; | ||
| 43 | readonly app: string; | ||
| 44 | readonly kind: "local-directory"; | ||
| 45 | readonly config: { readonly kind: "local-directory"; readonly path: string }; | ||
| 46 | readonly sourceRef?: string; | ||
| 47 | readonly description?: string; | ||
| 48 | readonly status: SourceStatus; | ||
| 49 | readonly updatedAt: number; | ||
| 50 | }; | ||
| 51 | |||
| 52 | export interface SourcesListResponse { | ||
| 53 | readonly sources: readonly AppSourceRecord[]; | ||
| 54 | } | ||
| 55 | |||
| 56 | export interface SourceDetailResponse { | ||
| 57 | readonly source: AppSourceRecord | null; | ||
| 58 | } | ||
| 59 | |||
| 60 | export type CreateSourceRequest = | ||
| 61 | | { | ||
| 62 | readonly kind: "git"; | ||
| 63 | readonly slug?: string; | ||
| 64 | readonly app?: string; | ||
| 65 | readonly url: string; | ||
| 66 | readonly ref?: string; | ||
| 67 | readonly token?: string; | ||
| 68 | } | ||
| 69 | | { | ||
| 70 | readonly kind: "local-directory"; | ||
| 71 | readonly slug?: string; | ||
| 72 | readonly app?: string; | ||
| 73 | readonly path: string; | ||
| 74 | }; | ||
| 75 | |||
| 76 | export interface CreateSourceResponse { | ||
| 77 | readonly source: AppSourceRecord; | ||
| 78 | } | ||
| 79 | |||
| 80 | export type SyncSourceResult = | ||
| 81 | | { | ||
| 82 | readonly status: "published" | "up-to-date"; | ||
| 83 | readonly sourceRef: string; | ||
| 84 | readonly tools: readonly string[]; | ||
| 85 | readonly errors?: undefined; | ||
| 86 | } | ||
| 87 | | { | ||
| 88 | readonly status: "failed"; | ||
| 89 | readonly sourceRef?: string; | ||
| 90 | readonly tools: readonly string[]; | ||
| 91 | readonly errors: readonly SyncDiagnostic[]; | ||
| 92 | }; | ||
| 93 | |||
| 94 | export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; | ||
| 95 | |||
| 96 | export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{ | ||
| 97 | readonly message: string; | ||
| 98 | }> {} | ||
| 99 | |||
| 100 | export const slugifyCustomToolsAppName = (value: string): string => | ||
| 101 | value | ||
| 102 | .trim() | ||
| 103 | .toLowerCase() | ||
| 104 | .replace(/[^a-z0-9-]+/g, "-") | ||
| 105 | .replace(/^-+|-+$/g, "") | ||
| 106 | .slice(0, 80); | ||
| 107 | |||
| 108 | export const validateCustomToolsAppSlug = (value: string): string | null => { | ||
| 109 | if (!value.trim()) return "Enter a source name."; | ||
| 110 | if (value !== slugifyCustomToolsAppName(value)) { | ||
| 111 | return "Use lowercase letters, numbers, and hyphens."; | ||
| 112 | } | ||
| 113 | return null; | ||
| 114 | }; | ||
| 115 | |||
| 116 | export const parseGitSourceUrl = ( | ||
| 117 | raw: string, | ||
| 118 | ): | ||
| 119 | | { readonly ok: true; readonly url: string; readonly name: string } | ||
| 120 | | { readonly ok: false; readonly message: string } => { | ||
| 121 | const value = raw.trim(); | ||
| 122 | if (!value) return { ok: false, message: "Enter a Git repository URL." }; | ||
| 123 | if (!URL.canParse(value)) return { ok: false, message: "Enter an https Git repository URL." }; | ||
| 124 | const parsed = new URL(value); | ||
| 125 | if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { | ||
| 126 | return { ok: false, message: "Git repository URLs must use http or https." }; | ||
| 127 | } | ||
| 128 | if (parsed.username || parsed.password) { | ||
| 129 | return { ok: false, message: "Do not include credentials in the URL. Use the token field." }; | ||
| 130 | } | ||
| 131 | const segments = parsed.pathname.split("/").filter(Boolean); | ||
| 132 | const last = segments.at(-1)?.replace(/\.git$/, "") ?? ""; | ||
| 133 | if (!last) return { ok: false, message: "Enter a repository URL with a path." }; | ||
| 134 | parsed.hash = ""; | ||
| 135 | return { ok: true, url: parsed.toString(), name: last }; | ||
| 136 | }; | ||
| 137 | |||
| 138 | export const suggestCustomToolsAppName = (urlOrPath: string): string => { | ||
| 139 | const parsed = parseGitSourceUrl(urlOrPath); | ||
| 140 | if (parsed.ok) return parsed.name; | ||
| 141 | const pathParts = urlOrPath.split("/").filter(Boolean); | ||
| 142 | return pathParts.at(-1) ?? ""; | ||
| 143 | }; | ||
| 144 | |||
| 145 | export const validateGitSourceUrl = (url: string): string | null => { | ||
| 146 | const parsed = parseGitSourceUrl(url); | ||
| 147 | return parsed.ok ? null : parsed.message; | ||
| 148 | }; | ||
| 149 | |||
| 150 | const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); | ||
| 151 | const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); | ||
| 152 | |||
| 153 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 154 | value !== null && typeof value === "object" && !Array.isArray(value); | ||
| 155 | |||
| 156 | const responseErrorMessage = (body: unknown, fallback: string): string => | ||
| 157 | isRecord(body) && typeof body.message === "string" | ||
| 158 | ? body.message | ||
| 159 | : isRecord(body) && typeof body.error === "string" | ||
| 160 | ? body.error | ||
| 161 | : fallback; | ||
| 162 | |||
| 163 | const parseJsonResponseEffect = <A>( | ||
| 164 | response: Response, | ||
| 165 | fallback: string, | ||
| 166 | ): Effect.Effect<A, CustomToolsClientError> => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | const text = yield* Effect.tryPromise({ | ||
| 169 | try: () => response.text(), | ||
| 170 | catch: () => new CustomToolsClientError({ message: fallback }), | ||
| 171 | }); | ||
| 172 | const body = | ||
| 173 | text.length > 0 | ||
| 174 | ? yield* decodeJsonText(text).pipe( | ||
| 175 | Effect.mapError(() => new CustomToolsClientError({ message: fallback })), | ||
| 176 | ) | ||
| 177 | : null; | ||
| 178 | if (!response.ok) { | ||
| 179 | return yield* new CustomToolsClientError({ | ||
| 180 | message: responseErrorMessage(body, fallback), | ||
| 181 | }); | ||
| 182 | } | ||
| 183 | return body as A; | ||
| 184 | }); | ||
| 185 | |||
| 186 | export const listCustomToolSourcesEffect = ( | ||
| 187 | fetchImpl: CustomToolsFetch = fetch, | ||
| 188 | ): Effect.Effect<SourcesListResponse, CustomToolsClientError> => | ||
| 189 | Effect.tryPromise({ | ||
| 190 | try: () => fetchImpl("/api/apps/sources", { credentials: "same-origin" }), | ||
| 191 | catch: () => new CustomToolsClientError({ message: "Failed to load custom tools sources." }), | ||
| 192 | }).pipe( | ||
| 193 | Effect.flatMap((response) => | ||
| 194 | parseJsonResponseEffect<SourcesListResponse>( | ||
| 195 | response, | ||
| 196 | "Failed to load custom tools sources.", | ||
| 197 | ), | ||
| 198 | ), | ||
| 199 | ); | ||
| 200 | |||
| 201 | export const listCustomToolSources = ( | ||
| 202 | fetchImpl: CustomToolsFetch = fetch, | ||
| 203 | ): Promise<SourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl)); | ||
| 204 | |||
| 205 | export const getCustomToolSourceEffect = ( | ||
| 206 | slug: string, | ||
| 207 | fetchImpl: CustomToolsFetch = fetch, | ||
| 208 | ): Effect.Effect<SourceDetailResponse, CustomToolsClientError> => | ||
| 209 | Effect.tryPromise({ | ||
| 210 | try: () => | ||
| 211 | fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, { | ||
| 212 | credentials: "same-origin", | ||
| 213 | }), | ||
| 214 | catch: () => new CustomToolsClientError({ message: "Failed to load custom tools source." }), | ||
| 215 | }).pipe( | ||
| 216 | Effect.flatMap((response) => | ||
| 217 | parseJsonResponseEffect<SourceDetailResponse>( | ||
| 218 | response, | ||
| 219 | "Failed to load custom tools source.", | ||
| 220 | ), | ||
| 221 | ), | ||
| 222 | ); | ||
| 223 | |||
| 224 | export const createCustomToolSourceEffect = ( | ||
| 225 | input: CreateSourceRequest, | ||
| 226 | fetchImpl: CustomToolsFetch = fetch, | ||
| 227 | ): Effect.Effect<CreateSourceResponse, CustomToolsClientError> => | ||
| 228 | Effect.tryPromise({ | ||
| 229 | try: () => | ||
| 230 | fetchImpl("/api/apps/sources", { | ||
| 231 | method: "POST", | ||
| 232 | headers: { "content-type": "application/json" }, | ||
| 233 | credentials: "same-origin", | ||
| 234 | body: encodeJsonText(input), | ||
| 235 | }), | ||
| 236 | catch: () => new CustomToolsClientError({ message: "Failed to create custom tools source." }), | ||
| 237 | }).pipe( | ||
| 238 | Effect.flatMap((response) => | ||
| 239 | parseJsonResponseEffect<CreateSourceResponse>( | ||
| 240 | response, | ||
| 241 | "Failed to create custom tools source.", | ||
| 242 | ), | ||
| 243 | ), | ||
| 244 | ); | ||
| 245 | |||
| 246 | export const syncCustomToolSourceEffect = ( | ||
| 247 | slug: string, | ||
| 248 | fetchImpl: CustomToolsFetch = fetch, | ||
| 249 | ): Effect.Effect<SyncSourceResult, CustomToolsClientError> => | ||
| 250 | Effect.tryPromise({ | ||
| 251 | try: () => | ||
| 252 | fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}/sync`, { | ||
| 253 | method: "POST", | ||
| 254 | credentials: "same-origin", | ||
| 255 | }), | ||
| 256 | catch: () => new CustomToolsClientError({ message: "Failed to sync custom tools source." }), | ||
| 257 | }).pipe( | ||
| 258 | Effect.flatMap((response) => | ||
| 259 | parseJsonResponseEffect<SyncSourceResult>(response, "Failed to sync custom tools source."), | ||
| 260 | ), | ||
| 261 | ); | ||
| 262 | |||
| 263 | export const removeCustomToolSourceEffect = ( | ||
| 264 | slug: string, | ||
| 265 | fetchImpl: CustomToolsFetch = fetch, | ||
| 266 | ): Effect.Effect<{ readonly removed: boolean }, CustomToolsClientError> => | ||
| 267 | Effect.tryPromise({ | ||
| 268 | try: () => | ||
| 269 | fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, { | ||
| 270 | method: "DELETE", | ||
| 271 | credentials: "same-origin", | ||
| 272 | }), | ||
| 273 | catch: () => new CustomToolsClientError({ message: "Failed to remove custom tools source." }), | ||
| 274 | }).pipe( | ||
| 275 | Effect.flatMap((response) => | ||
| 276 | parseJsonResponseEffect<{ readonly removed: boolean }>( | ||
| 277 | response, | ||
| 278 | "Failed to remove custom tools source.", | ||
| 279 | ), | ||
| 280 | ), | ||
| 281 | ); | ||
| 282 | |||
| 283 | export const syncStatusLabel = (result: SyncSourceResult): string => { | ||
| 284 | if (result.status === "published") return `Published ${result.tools.length} tools.`; | ||
| 285 | if (result.status === "up-to-date") return "Already up to date."; | ||
| 286 | return "Sync failed."; | ||
| 287 | }; | ||
| 288 | |||
| 289 | export const formatDiagnostics = ( | ||
| 290 | errors: readonly SyncDiagnostic[] | undefined, | ||
| 291 | ): readonly string[] => | ||
| 292 | (errors ?? []).flatMap((entry) => { | ||
| 293 | const head = `${entry.stage}: ${entry.message}`; | ||
| 294 | const details = entry.diagnostics?.map((d) => `${d.path}: ${d.message}`) ?? []; | ||
| 295 | return details.length > 0 ? [head, ...details] : [head]; | ||
| 296 | }); | ||
| 297 | |||
| 298 | export const formatSyncErrors = (result: SyncSourceResult): readonly string[] => | ||
| 299 | result.status === "failed" ? formatDiagnostics(result.errors) : []; | ||
| 300 | |||
| 301 | export const toolDiff = ( | ||
| 302 | before: readonly string[], | ||
| 303 | after: readonly string[], | ||
| 304 | ): { readonly added: readonly string[]; readonly removed: readonly string[] } => { | ||
| 305 | const beforeSet = new Set(before); | ||
| 306 | const afterSet = new Set(after); | ||
| 307 | return { | ||
| 308 | added: after.filter((tool) => !beforeSet.has(tool)), | ||
| 309 | removed: before.filter((tool) => !afterSet.has(tool)), | ||
| 310 | }; | ||
| 311 | }; | ||
| @@ -0,0 +1,174 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | |||
| 4 | import appsClientPlugin, { | ||
| 5 | APPS_INTEGRATION_SLUG, | ||
| 6 | CUSTOM_TOOLS_LABEL, | ||
| 7 | CUSTOM_TOOLS_PLUGIN_KEY, | ||
| 8 | createCustomToolSourceEffect, | ||
| 9 | formatSyncErrors, | ||
| 10 | listCustomToolSources, | ||
| 11 | parseGitSourceUrl, | ||
| 12 | removeCustomToolSourceEffect, | ||
| 13 | slugifyCustomToolsAppName, | ||
| 14 | suggestCustomToolsAppName, | ||
| 15 | syncCustomToolSourceEffect, | ||
| 16 | syncStatusLabel, | ||
| 17 | type AppSourceRecord, | ||
| 18 | type CustomToolsFetch, | ||
| 19 | } from "./plugin-client"; | ||
| 20 | import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model"; | ||
| 21 | |||
| 22 | const jsonResponse = (body: unknown, init?: ResponseInit): Response => | ||
| 23 | new Response(JSON.stringify(body), { | ||
| 24 | status: 200, | ||
| 25 | headers: { "content-type": "application/json" }, | ||
| 26 | ...init, | ||
| 27 | }); | ||
| 28 | |||
| 29 | const demoSource: AppSourceRecord = { | ||
| 30 | slug: "demo-tools", | ||
| 31 | app: "demo-tools", | ||
| 32 | kind: "git", | ||
| 33 | config: { | ||
| 34 | kind: "git", | ||
| 35 | url: "https://gitlab.com/acme/demo-tools.git", | ||
| 36 | ref: "main", | ||
| 37 | tokenProvider: "default", | ||
| 38 | tokenItemId: "apps/source-tokens/demo-tools", | ||
| 39 | }, | ||
| 40 | sourceRef: "abc123def456", | ||
| 41 | description: "Demo tools", | ||
| 42 | status: { | ||
| 43 | type: "published", | ||
| 44 | at: Date.parse("2026-07-06T12:00:00.000Z"), | ||
| 45 | tools: ["repo-summary", "stale-issues"], | ||
| 46 | }, | ||
| 47 | updatedAt: Date.parse("2026-07-06T12:00:00.000Z"), | ||
| 48 | }; | ||
| 49 | |||
| 50 | describe("custom tools console client", () => { | ||
| 51 | it("registers the Apps integration plugin", () => { | ||
| 52 | const plugin = appsClientPlugin({ sourceKinds: ["git"] }); | ||
| 53 | |||
| 54 | expect(plugin.id).toBe(CUSTOM_TOOLS_PLUGIN_KEY); | ||
| 55 | expect(plugin.integrationPlugin?.label).toBe(CUSTOM_TOOLS_LABEL); | ||
| 56 | expect(APPS_INTEGRATION_SLUG).toBe("apps"); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("validates and suggests generic https Git repository URLs", () => { | ||
| 60 | expect(parseGitSourceUrl("https://github.com/UsefulSoftwareCo/executor.git")).toMatchObject({ | ||
| 61 | ok: true, | ||
| 62 | name: "executor", | ||
| 63 | url: "https://github.com/UsefulSoftwareCo/executor.git", | ||
| 64 | }); | ||
| 65 | expect(parseGitSourceUrl("https://gitlab.com/acme/tools")).toMatchObject({ | ||
| 66 | ok: true, | ||
| 67 | name: "tools", | ||
| 68 | }); | ||
| 69 | expect(parseGitSourceUrl("ssh://gitlab.com/acme/tools")).toMatchObject({ | ||
| 70 | ok: false, | ||
| 71 | message: "Git repository URLs must use http or https.", | ||
| 72 | }); | ||
| 73 | expect(parseGitSourceUrl("https://x:secret@gitlab.com/acme/tools")).toMatchObject({ | ||
| 74 | ok: false, | ||
| 75 | message: "Do not include credentials in the URL. Use the token field.", | ||
| 76 | }); | ||
| 77 | expect(suggestCustomToolsAppName("https://gitlab.com/acme/tools.git")).toBe("tools"); | ||
| 78 | expect(slugifyCustomToolsAppName("Acme Tools!")).toBe("acme-tools"); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("uses the new source routes and keeps token-bearing input out of read responses", async () => { | ||
| 82 | const calls: Array<{ readonly url: string; readonly init?: RequestInit }> = []; | ||
| 83 | let createBody = ""; | ||
| 84 | const fetchImpl: CustomToolsFetch = async (input, init) => { | ||
| 85 | const url = String(input); | ||
| 86 | calls.push({ url, init }); | ||
| 87 | if (url === "/api/apps/sources" && init?.method === "POST") { | ||
| 88 | createBody = String(init.body); | ||
| 89 | return jsonResponse({ source: demoSource }); | ||
| 90 | } | ||
| 91 | if (url === "/api/apps/sources") { | ||
| 92 | return jsonResponse({ sources: [demoSource] }); | ||
| 93 | } | ||
| 94 | if (url === "/api/apps/sources/demo-tools/sync") { | ||
| 95 | return jsonResponse({ | ||
| 96 | status: "up-to-date", | ||
| 97 | sourceRef: "abc123def456", | ||
| 98 | tools: ["repo-summary", "stale-issues"], | ||
| 99 | }); | ||
| 100 | } | ||
| 101 | if (url === "/api/apps/sources/demo-tools") { | ||
| 102 | return jsonResponse({ removed: true }); | ||
| 103 | } | ||
| 104 | return jsonResponse({ error: "not found" }, { status: 404 }); | ||
| 105 | }; | ||
| 106 | |||
| 107 | const created = await Effect.runPromise( | ||
| 108 | createCustomToolSourceEffect( | ||
| 109 | { | ||
| 110 | kind: "git", | ||
| 111 | slug: "demo-tools", | ||
| 112 | app: "demo-tools", | ||
| 113 | url: "https://gitlab.com/acme/demo-tools.git", | ||
| 114 | token: "ghp_secret", | ||
| 115 | }, | ||
| 116 | fetchImpl, | ||
| 117 | ), | ||
| 118 | ); | ||
| 119 | const listed = await listCustomToolSources(fetchImpl); | ||
| 120 | const synced = await Effect.runPromise(syncCustomToolSourceEffect("demo-tools", fetchImpl)); | ||
| 121 | const removed = await Effect.runPromise(removeCustomToolSourceEffect("demo-tools", fetchImpl)); | ||
| 122 | |||
| 123 | expect(created.source.slug).toBe("demo-tools"); | ||
| 124 | expect(createBody).toContain("ghp_secret"); | ||
| 125 | expect(JSON.stringify(listed)).not.toContain("ghp_secret"); | ||
| 126 | expect(syncStatusLabel(synced)).toBe("Already up to date."); | ||
| 127 | expect(removed).toEqual({ removed: true }); | ||
| 128 | expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([ | ||
| 129 | "POST /api/apps/sources", | ||
| 130 | "GET /api/apps/sources", | ||
| 131 | "POST /api/apps/sources/demo-tools/sync", | ||
| 132 | "DELETE /api/apps/sources/demo-tools", | ||
| 133 | ]); | ||
| 134 | }); | ||
| 135 | |||
| 136 | it("models the source panel and sync notice", () => { | ||
| 137 | const panel = sourcePanelModel(demoSource, { | ||
| 138 | now: Date.parse("2026-07-06T12:05:00.000Z"), | ||
| 139 | }); | ||
| 140 | const notice = syncNoticeFromResult( | ||
| 141 | { | ||
| 142 | status: "published", | ||
| 143 | sourceRef: "def456abc123", | ||
| 144 | tools: ["repo-summary", "stale-issues", "extra-tool"], | ||
| 145 | }, | ||
| 146 | demoSource.status.type === "published" ? demoSource.status.tools : [], | ||
| 147 | ); | ||
| 148 | |||
| 149 | expect(panel.title).toBe("demo-tools"); | ||
| 150 | expect(panel.source).toBe("Git repository: https://gitlab.com/acme/demo-tools.git @ main"); | ||
| 151 | expect(panel.status).toBe("Published 5m ago"); | ||
| 152 | expect(panel.sourceRef).toBe("abc123def456"); | ||
| 153 | expect(panel.tools).toEqual(["repo-summary", "stale-issues"]); | ||
| 154 | expect(notice.message).toBe("Published 3 tools."); | ||
| 155 | expect(notice.added).toEqual(["extra-tool"]); | ||
| 156 | expect(notice.sourceRef).toBe("def456abc123"); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("formats failed sync diagnostics by stage", () => { | ||
| 160 | const errors = formatSyncErrors({ | ||
| 161 | status: "failed", | ||
| 162 | tools: [], | ||
| 163 | errors: [ | ||
| 164 | { | ||
| 165 | stage: "collect", | ||
| 166 | message: "schema export failed", | ||
| 167 | diagnostics: [{ path: "tools/x.ts", message: "unsupported schema" }], | ||
| 168 | }, | ||
| 169 | ], | ||
| 170 | }); | ||
| 171 | |||
| 172 | expect(errors).toEqual(["collect: schema export failed", "tools/x.ts: unsupported schema"]); | ||
| 173 | }); | ||
| 174 | }); | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | export { default } from "./plugin-client"; | ||
| 2 | export * from "./custom-tools-client"; | ||
| @@ -0,0 +1,16 @@ | |||
| 1 | import { defineClientPlugin } from "@executor-js/sdk/client"; | ||
| 2 | |||
| 3 | import { makeAppsIntegrationPlugin } from "./source-plugin"; | ||
| 4 | import type { AppSourceKind } from "./custom-tools-client"; | ||
| 5 | |||
| 6 | export { makeAppsIntegrationPlugin } from "./source-plugin"; | ||
| 7 | export * from "./custom-tools-client"; | ||
| 8 | |||
| 9 | export default function appsClientPlugin(config?: { | ||
| 10 | readonly sourceKinds?: readonly AppSourceKind[]; | ||
| 11 | }) { | ||
| 12 | return defineClientPlugin({ | ||
| 13 | id: "apps", | ||
| 14 | integrationPlugin: makeAppsIntegrationPlugin(config), | ||
| 15 | }); | ||
| 16 | } | ||
| @@ -0,0 +1,88 @@ | |||
| 1 | import type { AppSourceRecord, SyncSourceResult } from "./custom-tools-client"; | ||
| 2 | import { | ||
| 3 | formatDiagnostics, | ||
| 4 | formatSyncErrors, | ||
| 5 | syncStatusLabel, | ||
| 6 | toolDiff, | ||
| 7 | } from "./custom-tools-client"; | ||
| 8 | |||
| 9 | export interface SourcePanelModel { | ||
| 10 | readonly title: string; | ||
| 11 | readonly source: string; | ||
| 12 | readonly sourceRef: string; | ||
| 13 | readonly status: string; | ||
| 14 | readonly tools: readonly string[]; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface SyncNoticeModel { | ||
| 18 | readonly status: SyncSourceResult["status"]; | ||
| 19 | readonly message: string; | ||
| 20 | readonly added: readonly string[]; | ||
| 21 | readonly removed: readonly string[]; | ||
| 22 | readonly errors: readonly string[]; | ||
| 23 | readonly sourceRef?: string; | ||
| 24 | } | ||
| 25 | |||
| 26 | const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced"); | ||
| 27 | |||
| 28 | export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => { | ||
| 29 | const diffMs = Math.max(0, now - timestamp); | ||
| 30 | const min = Math.floor(diffMs / 60_000); | ||
| 31 | if (min < 1) return "just now"; | ||
| 32 | if (min < 60) return `${min}m ago`; | ||
| 33 | const hrs = Math.floor(min / 60); | ||
| 34 | if (hrs < 24) return `${hrs}h ago`; | ||
| 35 | const days = Math.floor(hrs / 24); | ||
| 36 | if (days < 7) return `${days}d ago`; | ||
| 37 | return new Date(timestamp).toLocaleDateString(); | ||
| 38 | }; | ||
| 39 | |||
| 40 | export const toolsCountLabel = (count: number): string => | ||
| 41 | `${count} ${count === 1 ? "tool" : "tools"}`; | ||
| 42 | |||
| 43 | export const sourcePanelModel = ( | ||
| 44 | source: AppSourceRecord, | ||
| 45 | options?: { readonly now?: number }, | ||
| 46 | ): SourcePanelModel => { | ||
| 47 | const tools = | ||
| 48 | source.status.type === "published" || source.status.type === "up-to-date" | ||
| 49 | ? source.status.tools | ||
| 50 | : []; | ||
| 51 | const sourceLabel = source.config.kind === "git" ? source.config.url : source.config.path; | ||
| 52 | const pin = source.config.kind === "git" && source.config.ref ? ` @ ${source.config.ref}` : ""; | ||
| 53 | const status = | ||
| 54 | source.status.type === "pending" | ||
| 55 | ? "Pending sync" | ||
| 56 | : source.status.type === "failed" | ||
| 57 | ? `Failed ${formatRelativeSyncTime(source.status.at, options?.now)}` | ||
| 58 | : `${source.status.type === "published" ? "Published" : "Up to date"} ${formatRelativeSyncTime( | ||
| 59 | source.status.at, | ||
| 60 | options?.now, | ||
| 61 | )}`; | ||
| 62 | return { | ||
| 63 | title: source.app, | ||
| 64 | source: `${source.kind === "git" ? "Git repository" : "Local directory"}: ${sourceLabel}${pin}`, | ||
| 65 | sourceRef: shortRef(source.sourceRef), | ||
| 66 | status, | ||
| 67 | tools, | ||
| 68 | }; | ||
| 69 | }; | ||
| 70 | |||
| 71 | export const sourceFailureLines = (source: AppSourceRecord): readonly string[] => | ||
| 72 | source.status.type === "failed" ? formatDiagnostics(source.status.errors) : []; | ||
| 73 | |||
| 74 | export const syncNoticeFromResult = ( | ||
| 75 | result: SyncSourceResult, | ||
| 76 | beforeTools: readonly string[], | ||
| 77 | ): SyncNoticeModel => { | ||
| 78 | const diff = | ||
| 79 | result.status === "failed" ? { added: [], removed: [] } : toolDiff(beforeTools, result.tools); | ||
| 80 | return { | ||
| 81 | status: result.status, | ||
| 82 | message: syncStatusLabel(result), | ||
| 83 | added: diff.added, | ||
| 84 | removed: diff.removed, | ||
| 85 | errors: formatSyncErrors(result), | ||
| 86 | ...(result.sourceRef ? { sourceRef: result.sourceRef.slice(0, 12) } : {}), | ||
| 87 | }; | ||
| 88 | }; | ||
| @@ -0,0 +1,36 @@ | |||
| 1 | import { lazy } from "react"; | ||
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | CUSTOM_TOOLS_LABEL, | ||
| 6 | CUSTOM_TOOLS_PLUGIN_KEY, | ||
| 7 | type AppSourceKind, | ||
| 8 | } from "./custom-tools-client"; | ||
| 9 | |||
| 10 | const importAccounts = () => import("./CustomToolsAccountsPanel"); | ||
| 11 | |||
| 12 | export const makeAppsIntegrationPlugin = (config?: { | ||
| 13 | readonly sourceKinds?: readonly AppSourceKind[]; | ||
| 14 | }): IntegrationPlugin => { | ||
| 15 | const sourceKinds = config?.sourceKinds ?? ["git"]; | ||
| 16 | const Add = lazy(async () => { | ||
| 17 | const mod = await import("./AddCustomToolsSource"); | ||
| 18 | const AddCustomToolsSource = mod.default; | ||
| 19 | return { | ||
| 20 | default: (props: Parameters<typeof mod.default>[0]) => ( | ||
| 21 | <AddCustomToolsSource {...props} sourceKinds={sourceKinds} /> | ||
| 22 | ), | ||
| 23 | }; | ||
| 24 | }); | ||
| 25 | const Accounts = lazy(importAccounts); | ||
| 26 | return { | ||
| 27 | key: CUSTOM_TOOLS_PLUGIN_KEY, | ||
| 28 | label: CUSTOM_TOOLS_LABEL, | ||
| 29 | add: Add, | ||
| 30 | accounts: Accounts, | ||
| 31 | preload: () => { | ||
| 32 | void import("./AddCustomToolsSource"); | ||
| 33 | void importAccounts(); | ||
| 34 | }, | ||
| 35 | }; | ||
| 36 | }; | ||
| @@ -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 | ); |
| @@ -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 | } |
| @@ -95,6 +95,11 @@ const schemaDescription = (schema: JsonSchema): string | undefined => { · 94 unmodified lines | |||
| 95 | return undefined; | 95 | return undefined; |
| 96 | }; | 96 | }; |
| 97 | 97 | ||
| 98 | export const emptyEnumGuidance = (description: string | undefined): string => { | ||
| 99 | const integration = description?.match(/^Connection to use for .+ \(([^)]+)\)$/)?.[1]; | ||
| 100 | return integration ? `Connect ${integration} first.` : "No options available."; | ||
| 101 | }; | ||
| 102 | |||
| 98 | const DEFAULT_BY_TYPE: Record<string, () => unknown> = { | 103 | const 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. */ |
| 109 | export const defaultForSchema = (schema: JsonSchema, root: JsonSchema): unknown => { | 114 | export 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 present | 232 | // 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 | ||
| 230 | function ObjectFields(props: { | 236 | function 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-arg | 262 | // 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; |
| @@ -0,0 +1,16 @@ | |||
| 1 | export type IntegrationDetailSearchTab = "accounts" | "source" | "tools"; | ||
| 2 | export type IntegrationDetailInternalTab = "accounts" | "tools"; | ||
| 3 | |||
| 4 | export const integrationDetailTabForAddCompletion = ( | ||
| 5 | pluginKey: string, | ||
| 6 | ): IntegrationDetailSearchTab | undefined => (pluginKey === "apps" ? "source" : undefined); | ||
| 7 | |||
| 8 | export const integrationDetailInternalTabFromSearch = ( | ||
| 9 | tab: IntegrationDetailSearchTab | undefined, | ||
| 10 | ): IntegrationDetailInternalTab => (tab === "tools" ? "tools" : "accounts"); | ||
| 11 | |||
| 12 | export const integrationDetailSearchTabForInternal = ( | ||
| 13 | integrationKind: string | undefined, | ||
| 14 | tab: IntegrationDetailInternalTab, | ||
| 15 | ): IntegrationDetailSearchTab => | ||
| 16 | tab === "tools" ? "tools" : integrationKind === "apps" ? "source" : "accounts"; | ||
| @@ -1,8 +1,11 @@ | |||
| 1 | import { Suspense } from "react"; | 1 | import { Suspense } from "react"; |
| 2 | import { useAtomRefresh } from "@effect/atom-react"; | ||
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 3 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | 4 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 5 | import { integrationsOptimisticAtom } from "../api/atoms"; | ||
| 4 | import { trackEvent } from "../api/analytics"; | 6 | import { trackEvent } from "../api/analytics"; |
| 5 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 7 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 8 | import { integrationDetailTabForAddCompletion } from "../lib/integration-detail-tabs"; | ||
| 6 | 9 | ||
| 7 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 8 | // Page | 11 | // 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 | slug | 69 | 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 | }} |
| @@ -36,6 +36,12 @@ import { Skeleton } from "../components/skeleton"; · 35 unmodified lines | |||
| 36 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 36 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 37 | import { ErrorState } from "../components/error-state"; | 37 | import { ErrorState } from "../components/error-state"; |
| 38 | import { isAsyncResultLoading } from "../lib/async-result"; | 38 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 39 | import { | ||
| 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 to | 46 | // 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 is | 47 | // 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 | ||
| 55 | export function IntegrationDetailPage(props: { namespace: string }) { | 61 | export 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 kind | 169 | // 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 | Edit | 399 | 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 | <Button | 418 | <Button |
| @@ -415,12 +447,16 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 28 unmodified lines | |||
| 415 | 447 | ||
| 416 | <Tabs | 448 | <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> |
| @@ -49,6 +49,7 @@ import { ErrorState } from "../components/error-state"; · 48 unmodified lines | |||
| 49 | import { isAsyncResultLoading } from "../lib/async-result"; | 49 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 50 | 50 | ||
| 51 | const KIND_TO_PLUGIN_KEY: Record<string, string> = { | 51 | const 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 | ||
| 70 | const detectGitRepository = ( | ||
| 71 | raw: string, | ||
| 72 | ): { readonly endpoint: string; readonly slug: string } | null => { | ||
| 73 | const value = raw.trim(); | ||
| 74 | if (!URL.canParse(value)) return null; | ||
| 75 | const parsed = new URL(value); | ||
| 76 | if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; | ||
| 77 | if (parsed.username || parsed.password) return null; | ||
| 78 | const segments = parsed.pathname.split("/").filter(Boolean); | ||
| 79 | const name = segments.at(-1)?.replace(/\.git$/, "") ?? ""; | ||
| 80 | if (!name) return null; | ||
| 81 | parsed.hash = ""; | ||
| 82 | return { | ||
| 83 | endpoint: parsed.toString(), | ||
| 84 | slug: name | ||
| 85 | .trim() | ||
| 86 | .toLowerCase() | ||
| 87 | .replace(/[^a-z0-9-]+/g, "-") | ||
| 88 | .replace(/^-+|-+$/g, "") | ||
| 89 | .slice(0, 80), | ||
| 90 | }; | ||
| 91 | }; | ||
| 92 | |||
| 69 | // --------------------------------------------------------------------------- | 93 | // --------------------------------------------------------------------------- |
| 70 | // Page | 94 | // 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 without | 219 | // 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({ |
| @@ -1,10 +1,25 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 2 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 3 | ||
| 3 | import { IntegrationDetailPage } from "../pages/integration-detail"; | 4 | import { IntegrationDetailPage } from "../pages/integration-detail"; |
| 5 | import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs"; | ||
| 6 | |||
| 7 | const SearchParams = Schema.toStandardSchemaV1( | ||
| 8 | Schema.Struct({ | ||
| 9 | tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])), | ||
| 10 | }), | ||
| 11 | ); | ||
| 4 | 12 | ||
| 5 | export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({ | 13 | export 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 | }); |