Unified · Split
| @@ -801,16 +801,30 @@ · 800 unmodified lines | ||
| 801 | 801 | "zod": "4.3.6", |
| 802 | 802 | }, |
| 803 | 803 | "devDependencies": { |
| 804 | + "@effect/atom-react": "catalog:", | |
| 804 | 805 | "@effect/vitest": "catalog:", |
| 806 | + "@executor-js/react": "workspace:*", | |
| 805 | 807 | "@types/node": "catalog:", |
| 808 | + "@types/react": "catalog:", | |
| 806 | 809 | "bun-types": "catalog:", |
| 807 | 810 | "effect": "catalog:", |
| 811 | + "react": "catalog:", | |
| 808 | 812 | "tsup": "catalog:", |
| 809 | 813 | "vitest": "catalog:", |
| 810 | 814 | }, |
| 811 | 815 | "peerDependencies": { |
| 816 | + "@effect/atom-react": "catalog:", | |
| 817 | + "@executor-js/react": "workspace:*", | |
| 818 | + "@tanstack/react-router": "catalog:", | |
| 812 | 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 | 829 | "packages/plugins/desktop-settings": { |
| 816 | 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 | 363 | describe("tool catalog sync safety", () => { |
| 364 | 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 | 367 | Effect.scoped( |
| 368 | 368 | Effect.gen(function* () { |
| 369 | 369 | let empty = false; |
| 370 | 370 | const guardedPlugin = definePlugin(() => ({ |
| 371 | 371 | id: "guarded" as const, |
| 372 | + remoteToolCatalog: true, | |
| 372 | 373 | credentialProviders: [memoryProvider()], |
| 373 | 374 | storage: () => ({}), |
| 374 | 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 | 488 | it.effect("explicit refresh accepts an authoritative empty catalog", () => |
| 428 | 489 | Effect.scoped( |
| 429 | 490 | Effect.gen(function* () { |
| @@ -2167,7 +2167,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2166 unmodified lines | ||
| 2167 | 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 | 2175 | const keptRows = yield* core.findMany("tool", { where }); |
| 2172 | 2176 | if (keptRows.length > 0) { |
| 2173 | 2177 | const reason = |
| @@ -20,6 +20,8 @@ · 19 unmodified lines | ||
| 20 | 20 | "./api": "./src/api.ts", |
| 21 | 21 | "./authoring": "./src/authoring.ts", |
| 22 | 22 | "./cloud": "./src/cloud.ts", |
| 23 | + "./client": "./src/react/plugin-client.tsx", | |
| 24 | + "./react": "./src/react/index.ts", | |
| 23 | 25 | "./selfhost": "./src/selfhost.ts", |
| 24 | 26 | "./testing": "./src/testing/index.ts" |
| 25 | 27 | }, |
| @@ -39,14 +41,36 @@ · 13 unmodified lines | ||
| 39 | 41 | "zod": "4.3.6" |
| 40 | 42 | }, |
| 41 | 43 | "devDependencies": { |
| 44 | + "@effect/atom-react": "catalog:", | |
| 42 | 45 | "@effect/vitest": "catalog:", |
| 46 | + "@executor-js/react": "workspace:*", | |
| 43 | 47 | "@types/node": "catalog:", |
| 48 | + "@types/react": "catalog:", | |
| 44 | 49 | "bun-types": "catalog:", |
| 45 | 50 | "effect": "catalog:", |
| 51 | + "react": "catalog:", | |
| 46 | 52 | "tsup": "catalog:", |
| 47 | 53 | "vitest": "catalog:" |
| 48 | 54 | }, |
| 49 | 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 | 137 | export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => { |
| 138 | 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 | 146 | if (url.username || url.password) |
| 141 | 147 | throw new Error("git source URL must not include embedded credentials"); |
| 142 | 148 | if (hasTokenLikeQuery(url)) |
| 143 | 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 | 151 | throw new Error("git source URL host is not allowed"); |
| 146 | 152 | } |
| 147 | 153 | return url; |
| @@ -58,7 +58,18 @@ const makeMemoryStore = (): AppsStore & { · 57 unmodified lines | ||
| 58 | 58 | for (const key of rows.keys()) rows.delete(key); |
| 59 | 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 | 73 | getTool: (name) => |
| 63 | 74 | Effect.sync(() => { |
| 64 | 75 | const row = descriptor?.tools.find((item) => item.name === name); |
| @@ -74,7 +74,17 @@ const makeSyncStore = (): AppsStore & { · 73 unmodified lines | ||
| 74 | 74 | descriptor = next; |
| 75 | 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 | 88 | getTool: (name) => |
| 79 | 89 | Effect.sync(() => { |
| 80 | 90 | const tool = descriptor?.tools.find((item) => item.name === name); |
| @@ -145,6 +155,7 @@ const makeInvokeStore = (input: { · 64 unmodified lines | ||
| 145 | 155 | getBlob: () => Effect.succeed(input.bundle), |
| 146 | 156 | getDescriptorRecord: () => Effect.succeed(null), |
| 147 | 157 | putPublished: () => Effect.void, |
| 158 | + removePublished: () => Effect.void, | |
| 148 | 159 | listActiveTools: () => Effect.succeed([]), |
| 149 | 160 | getTool: () => |
| 150 | 161 | Effect.succeed({ |
| @@ -190,6 +201,7 @@ describe("apps plugin schema projection", () => { · 39 unmodified lines | ||
| 190 | 201 | getBlob: () => Effect.succeed(null), |
| 191 | 202 | getDescriptorRecord: () => Effect.succeed(null), |
| 192 | 203 | putPublished: () => Effect.void, |
| 204 | + removePublished: () => Effect.void, | |
| 193 | 205 | listActiveTools: () => Effect.succeed([]), |
| 194 | 206 | putSource: () => Effect.void, |
| 195 | 207 | listSources: () => Effect.succeed([]), |
| @@ -233,14 +245,18 @@ describe("apps plugin schema projection", () => { · 37 unmodified lines | ||
| 233 | 245 | ); |
| 234 | 246 | expect(result.inputSchema).toMatchObject({ |
| 235 | 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 | 252 | inboxes: { |
| 253 | + default: ["tools.gmail.org.work", "tools.gmail.user.personal"], | |
| 238 | 254 | items: { |
| 239 | 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 | 207 | ): Effect.Effect<readonly string[], unknown> => |
| 208 | 208 | ctx.storage |
| 209 | 209 | .listActiveTools() |
| 210 | − .pipe( | |
| 211 | − Effect.map((tools) => | |
| 212 | − tools | |
| 213 | − .filter((tool) => tool.name.startsWith(`${app}__`) || tool.name === app) | |
| 214 | − .map((tool) => tool.name), | |
| 215 | − ), | |
| 216 | − ); | |
| 210 | + .pipe(Effect.map((tools) => tools.filter((tool) => tool.app === app).map((tool) => tool.name))); | |
| 217 | 211 | |
| 218 | 212 | const makeAppsExtension = ( |
| 219 | 213 | ctx: PluginCtx<AppsStore>, |
| @@ -281,6 +275,14 @@ const makeAppsExtension = ( · 61 unmodified lines | ||
| 281 | 275 | deleteSource: (slug: string) => |
| 282 | 276 | Effect.gen(function* () { |
| 283 | 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 | 286 | if ( |
| 285 | 287 | record?.config.kind === "git" && |
| 286 | 288 | record.config.tokenProvider && |
| @@ -435,6 +437,7 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 148 unmodified lines | ||
| 435 | 437 | definePlugin(() => ({ |
| 436 | 438 | id: "apps", |
| 437 | 439 | packageName: "@executor-js/plugin-apps", |
| 440 | + clientConfig: { sourceKinds: options?.sourceKinds ?? ["git", "local-directory"] }, | |
| 438 | 441 | pluginStorage: { |
| 439 | 442 | [descriptorCollection.name]: descriptorCollection, |
| 440 | 443 | [toolCollection.name]: toolCollection, |
| @@ -546,10 +549,29 @@ export const projectAppsToolSchema = ( · 105 unmodified lines | ||
| 546 | 549 | const enumValues = connections.map((connection) => String(connection.address)); |
| 547 | 550 | properties[field] = |
| 548 | 551 | decl.mode === "many" |
| 549 | − ? { type: "array", items: { type: "string", enum: enumValues } } | |
| 550 | − : { type: "string", enum: enumValues }; | |
| 552 | + ? { | |
| 553 | + type: "array", | |
| 554 | + items: { type: "string", enum: enumValues }, | |
| 555 | + default: enumValues, | |
| 556 | + } | |
| 557 | + : { | |
| 558 | + type: "string", | |
| 559 | + enum: enumValues, | |
| 560 | + ...(enumValues[0] ? { default: enumValues[0] } : {}), | |
| 561 | + }; | |
| 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 | 577 | export const appsPlugin = makeAppsPlugin(); |
| @@ -177,6 +177,27 @@ describe("apps store", () => { · 176 unmodified lines | ||
| 177 | 177 | yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1"); |
| 178 | 178 | const active = yield* store.listActiveTools(); |
| 179 | 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 | 102 | owner: Owner, |
| 103 | 103 | expectedSourceRef: string | null, |
| 104 | 104 | ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>; |
| 105 | + readonly removePublished: (app: string, owner: Owner) => Effect.Effect<void, StorageFailure>; | |
| 105 | 106 | readonly listActiveTools: () => Effect.Effect< |
| 106 | − readonly AppDescriptor["tools"][number][], | |
| 107 | + readonly (AppDescriptor["tools"][number] & { readonly app: string })[], | |
| 107 | 108 | StorageFailure |
| 108 | 109 | >; |
| 109 | 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 | 235 | listActiveTools: () => |
| 219 | 236 | tools.query({ where: { tombstoned: false } }).pipe( |
| 220 | 237 | Effect.map((entries) => |
| 221 | 238 | entries.map((entry) => ({ |
| 239 | + app: entry.data.app, | |
| 222 | 240 | name: entry.data.name, |
| 223 | 241 | sourcePath: "", |
| 224 | 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 | 227 | it.effect("rejects private git hosts under cloud posture", () => |
| 228 | 228 | Effect.gen(function* () { |
| 229 | 229 | for (const url of [ |
| 230 | + "http://localhost/repo.git", | |
| 230 | 231 | "https://localhost/repo.git", |
| 231 | 232 | "https://127.0.0.1/repo.git", |
| 232 | 233 | "https://[::ffff:127.0.0.1]/repo.git", |
| @@ -239,6 +240,11 @@ describe("git app sources", () => { · 6 unmodified lines | ||
| 239 | 240 | const exit = yield* Effect.exit(parseGitSourceUrl(url)); |
| 240 | 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 | 248 | expect(yield* parseGitSourceUrl("https://gitlab.com/acme/tools.git")).toBeInstanceOf(URL); |
| 243 | 249 | }), |
| 244 | 250 | ); |
| @@ -9,6 +9,7 @@ · 8 unmodified lines | ||
| 9 | 9 | "types": ["bun-types", "node"], |
| 10 | 10 | "noUnusedLocals": true, |
| 11 | 11 | "noImplicitOverride": true, |
| 12 | + "jsx": "react-jsx", | |
| 12 | 13 | "plugins": [ |
| 13 | 14 | { |
| 14 | 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 | 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 | 103 | const DEFAULT_BY_TYPE: Record<string, () => unknown> = { |
| 99 | 104 | string: () => "", |
| 100 | 105 | number: () => 0, |
| @@ -105,9 +110,10 @@ const DEFAULT_BY_TYPE: Record<string, () => unknown> = { · 4 unmodified lines | ||
| 105 | 110 | }; |
| 106 | 111 | |
| 107 | 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 | 114 | export const defaultForSchema = (schema: JsonSchema, root: JsonSchema): unknown => { |
| 110 | 115 | const s = deepResolve(schema, root); |
| 116 | + if (s.default !== undefined) return s.default; | |
| 111 | 117 | if (s.const !== undefined) return s.const; |
| 112 | 118 | if (Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0]; |
| 113 | 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 | |
| 227 | −// optionals, then "Add" rows for absent optionals) | |
| 232 | +// ObjectFields — renders an object's properties (required/default-backed first, | |
| 233 | +// then present optionals, then "Add" rows for absent optionals) | |
| 228 | 234 | // --------------------------------------------------------------------------- |
| 229 | 235 | |
| 230 | 236 | function ObjectFields(props: { |
| @@ -242,11 +248,15 @@ function ObjectFields(props: { · 11 unmodified lines | ||
| 242 | 248 | const requiredSet = new Set(schema.required ?? []); |
| 243 | 249 | const entries = Object.entries(properties); |
| 244 | 250 | |
| 245 | − const present = entries.filter( | |
| 246 | − ([key]: [string, JsonSchema]) => key in value || requiredSet.has(key), | |
| 247 | − ); | |
| 251 | + const present = entries.filter(([key, propSchema]: [string, JsonSchema]) => { | |
| 252 | + const resolved = deepResolve(propSchema, root); | |
| 253 | + return key in value || requiredSet.has(key) || resolved.default !== undefined; | |
| 254 | + }); | |
| 248 | 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 | 262 | // No properties → render nothing (no "takes no arguments" noise); a zero-arg |
| @@ -316,6 +326,7 @@ function JsonSchemaField(props: { · 63 unmodified lines | ||
| 316 | 326 | const { root, name, fieldId, required, value, present, onChange, depth, disabled } = props; |
| 317 | 327 | const schema = deepResolve(props.schema, root); |
| 318 | 328 | const description = schemaDescription(schema); |
| 329 | + const fieldValue = value === undefined && schema.default !== undefined ? schema.default : value; | |
| 319 | 330 | |
| 320 | 331 | const header = ( |
| 321 | 332 | <div className="flex items-center justify-between gap-2"> |
| @@ -353,7 +364,7 @@ function JsonSchemaField(props: { · 31 unmodified lines | ||
| 353 | 364 | schema={schema} |
| 354 | 365 | root={root} |
| 355 | 366 | fieldId={fieldId} |
| 356 | − value={value} | |
| 367 | + value={fieldValue} | |
| 357 | 368 | onChange={onChange} |
| 358 | 369 | depth={depth} |
| 359 | 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 | 433 | // enum — native select. Optional/unset gets a leading empty option. |
| 411 | 434 | if (Array.isArray(schema.enum) && schema.enum.length > 0) { |
| 412 | 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 | 1 | import { Suspense } from "react"; |
| 2 | +import { useAtomRefresh } from "@effect/atom-react"; | |
| 2 | 3 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 4 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 5 | +import { integrationsOptimisticAtom } from "../api/atoms"; | |
| 4 | 6 | import { trackEvent } from "../api/analytics"; |
| 5 | 7 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 8 | +import { integrationDetailTabForAddCompletion } from "../lib/integration-detail-tabs"; | |
| 6 | 9 | |
| 7 | 10 | // --------------------------------------------------------------------------- |
| 8 | 11 | // Page |
| @@ -18,6 +21,7 @@ export function AddIntegrationPage(props: { · 9 unmodified lines | ||
| 18 | 21 | const { pluginKey, url, preset, namespace } = props; |
| 19 | 22 | const navigate = useNavigate(); |
| 20 | 23 | const integrationPlugins = useIntegrationPlugins(); |
| 24 | + const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); | |
| 21 | 25 | |
| 22 | 26 | const plugin = integrationPlugins.find((p) => p.key === pluginKey); |
| 23 | 27 | |
| @@ -59,9 +63,15 @@ export function AddIntegrationPage(props: { · 35 unmodified lines | ||
| 59 | 63 | plugin_key: pluginKey, |
| 60 | 64 | ...(slug ? { integration_slug: slug } : {}), |
| 61 | 65 | }); |
| 66 | + refreshIntegrations(); | |
| 67 | + const tab = integrationDetailTabForAddCompletion(pluginKey); | |
| 62 | 68 | void navigate( |
| 63 | 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 | 75 | : { to: "/{-$orgSlug}" }, |
| 66 | 76 | ); |
| 67 | 77 | }} |
| @@ -36,6 +36,12 @@ import { Skeleton } from "../components/skeleton"; · 35 unmodified lines | ||
| 36 | 36 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 37 | 37 | import { ErrorState } from "../components/error-state"; |
| 38 | 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 | 46 | // v2: the route's `namespace` param is the integration slug. Tools belong to |
| 41 | 47 | // the integration's per-owner connections; a tool's policy id is |
| @@ -52,7 +58,10 @@ type ToolRow = { · 10 unmodified lines | ||
| 52 | 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 | 65 | const { namespace } = props; |
| 57 | 66 | const slug = IntegrationSlug.make(namespace); |
| 58 | 67 | const integrationPlugins = useIntegrationPlugins(); |
| @@ -89,7 +98,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 30 unmodified lines | ||
| 89 | 98 | const [deleting, setDeleting] = useState(false); |
| 90 | 99 | const [refreshing, setRefreshing] = useState(false); |
| 91 | 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 | 104 | const [manualAccountHandoff, setManualAccountHandoff] = |
| 94 | 105 | useState<IntegrationAccountHandoff | null>(null); |
| 95 | 106 | const [locationSearch] = useState(() => |
| @@ -101,9 +112,14 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 5 unmodified lines | ||
| 101 | 112 | setEditSheetOpen(false); |
| 102 | 113 | }, [namespace]); |
| 103 | 114 | |
| 115 | + useEffect(() => { | |
| 116 | + setActiveTab(integrationDetailInternalTabFromSearch(props.tab)); | |
| 117 | + }, [namespace, props.tab]); | |
| 118 | + | |
| 104 | 119 | const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null; |
| 105 | 120 | useExecutorDocumentTitle(integrationData?.name || namespace); |
| 106 | 121 | const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in"; |
| 122 | + const isAppsIntegration = integrationData?.kind === "apps"; | |
| 107 | 123 | const currentTab = isBuiltInIntegration ? "tools" : activeTab; |
| 108 | 124 | const canRefresh = integrationData?.canRefresh ?? false; |
| 109 | 125 | const canRemove = integrationData?.canRemove ?? false; |
| @@ -153,6 +169,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 43 unmodified lines | ||
| 153 | 169 | // Find the plugin edit component based on integration kind |
| 154 | 170 | const editPlugin = useMemo(() => { |
| 155 | 171 | if (!integrationData) return null; |
| 172 | + if (integrationData.kind === "apps") { | |
| 173 | + return integrationPlugins.find((p) => p.key === "apps") ?? null; | |
| 174 | + } | |
| 156 | 175 | return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null; |
| 157 | 176 | }, [integrationData, integrationPlugins]); |
| 158 | 177 | |
| @@ -347,6 +366,18 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 188 unmodified lines | ||
| 347 | 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 | 381 | return ( |
| 351 | 382 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> |
| 352 | 383 | {/* Header bar */} |
| @@ -363,7 +394,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 10 unmodified lines | ||
| 363 | 394 | </div> |
| 364 | 395 | |
| 365 | 396 | <div className="flex shrink-0 items-center gap-2"> |
| 366 | − {!confirmDelete && !isBuiltInIntegration && integrationData && ( | |
| 397 | + {!confirmDelete && !isBuiltInIntegration && !isAppsIntegration && integrationData && ( | |
| 367 | 398 | <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}> |
| 368 | 399 | Edit |
| 369 | 400 | </Button> |
| @@ -381,6 +412,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 11 unmodified lines | ||
| 381 | 412 | )} |
| 382 | 413 | |
| 383 | 414 | {canRemove && |
| 415 | + !isAppsIntegration && | |
| 384 | 416 | (confirmDelete ? ( |
| 385 | 417 | <div className="flex items-center gap-2"> |
| 386 | 418 | <Button |
| @@ -415,12 +447,16 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 28 unmodified lines | ||
| 415 | 447 | |
| 416 | 448 | <Tabs |
| 417 | 449 | value={currentTab} |
| 418 | − onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")} | |
| 450 | + onValueChange={handleTabChange} | |
| 419 | 451 | className="min-h-0 flex-1 gap-0 overflow-hidden" |
| 420 | 452 | > |
| 421 | 453 | <div className="shrink-0 border-b border-border/60 px-4 py-2"> |
| 422 | 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 | 460 | <TabsTrigger value="tools">Tools</TabsTrigger> |
| 425 | 461 | </TabsList> |
| 426 | 462 | </div> |
| @@ -49,6 +49,7 @@ import { ErrorState } from "../components/error-state"; · 48 unmodified lines | ||
| 49 | 49 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 50 | 50 | |
| 51 | 51 | const KIND_TO_PLUGIN_KEY: Record<string, string> = { |
| 52 | + apps: "apps", | |
| 52 | 53 | openapi: "openapi", |
| 53 | 54 | mcp: "mcp", |
| 54 | 55 | graphql: "graphql", |
| @@ -66,6 +67,29 @@ const bestDetection = ( · 11 unmodified lines | ||
| 66 | 67 | ): IntegrationDetectionResult | undefined => |
| 67 | 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 | 94 | // Page |
| 71 | 95 | // --------------------------------------------------------------------------- |
| @@ -176,6 +200,22 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 104 unmodified lines | ||
| 176 | 200 | if (!trimmed) return; |
| 177 | 201 | setDetecting(true); |
| 178 | 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 | 219 | // Detection is read-only — it inspects a URL and returns candidates without |
| 180 | 220 | // mutating the catalog, so it invalidates nothing. |
| 181 | 221 | const exit = await doDetect({ |
| @@ -1,10 +1,25 @@ | ||
| 1 | +import { Schema } from "effect"; | |
| 1 | 2 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 3 | |
| 3 | 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 | 13 | export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({ |
| 14 | + validateSearch: SearchParams, | |
| 6 | 15 | component: () => { |
| 7 | 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 | }); |