* Add console support for custom app tools * Only claim unambiguous git URLs in connect-dialog detection * Add custom tools console recording * Omit the required key when schema projection empties it * Assert projection output stays JSON-encodable without JSON.parse * Derive app tool schemas via Standard Schema * Cover zod schemas in custom tools e2e * Pass the required target option to Standard Schema converters * Make app sources top-level integrations * Limit local directory app sources to local host * Add local directory picker endpoint * Update local app dependency lockfile * Cover local directory picker in local e2e * Fix CLI node ambient types * Cover custom tools on cloud target * Complete local custom tools picker e2e * Require auth in custom tools git e2e * Exercise custom tools run tab * Cover custom tools collect failure recovery * Handle cloud auth status in custom tools e2e * Show local app source shape in directory picker * Tighten directory picker e2e tag assertion * Remove implicit GitHub connection token from git sources * Package workerd for local binaries * Trust bundled root certificates in workerd outbound TLS workerd's built-in internet service cannot read the Windows system trust store, so npm installs inside the publish worker failed TLS verification. Declare the internet service explicitly with the runtime's bundled Mozilla roots, surface workerd stderr in startup failures, and fail the bundle when a dependency install warning is present instead of publishing a broken bundle. * Add packed-binary custom tools e2e scenarios The new CLI VM scenario proves the compiled executor binary can publish and invoke local-directory custom tools inside macOS, Linux, and Windows guests. The desktop VM scenario proves the packaged sidecar daemon does the same on desktop bundles, and these manual-run VM projects remain outside the default CI chain by design.
Unified · Split
| @@ -37,6 +37,7 @@ · 36 unmodified lines | |||
| 37 | }, | 37 | }, |
| 38 | "devDependencies": { | 38 | "devDependencies": { |
| 39 | "@effect/vitest": "catalog:", | 39 | "@effect/vitest": "catalog:", |
| 40 | "@types/node": "catalog:", | ||
| 40 | "bun-types": "catalog:", | 41 | "bun-types": "catalog:", |
| 41 | "typescript": "catalog:", | 42 | "typescript": "catalog:", |
| 42 | "vitest": "catalog:" | 43 | "vitest": "catalog:" |
| @@ -0,0 +1,192 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-promise-reject, executor/no-promise-catch -- boundary: standalone build smoke harness tears down a spawned packed binary */ | ||
| 2 | import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | ||
| 3 | import { tmpdir } from "node:os"; | ||
| 4 | import { join, resolve } from "node:path"; | ||
| 5 | import { spawn, type Subprocess } from "bun"; | ||
| 6 | |||
| 7 | const binary = process.argv[2]; | ||
| 8 | if (!binary) throw new Error("usage: bun apps/cli/scripts/smoke-packed-apps.ts <packed-binary>"); | ||
| 9 | |||
| 10 | const AUTH_TOKEN = "packed-apps-smoke-token"; | ||
| 11 | const READY_TIMEOUT_MS = 45_000; | ||
| 12 | |||
| 13 | const fail = (message: string): never => { | ||
| 14 | throw new Error(`[smoke-packed-apps] FAIL: ${message}`); | ||
| 15 | }; | ||
| 16 | |||
| 17 | const requestJson = async <T>(origin: string, path: string, init: RequestInit = {}): Promise<T> => { | ||
| 18 | const response = await fetch(new URL(path, origin), { | ||
| 19 | ...init, | ||
| 20 | headers: { | ||
| 21 | authorization: `Bearer ${AUTH_TOKEN}`, | ||
| 22 | ...(init.body === undefined ? {} : { "content-type": "application/json" }), | ||
| 23 | ...init.headers, | ||
| 24 | }, | ||
| 25 | }); | ||
| 26 | const text = await response.text(); | ||
| 27 | if (!response.ok) fail(`${init.method ?? "GET"} ${path} returned ${response.status}: ${text}`); | ||
| 28 | return (text.length > 0 ? JSON.parse(text) : null) as T; | ||
| 29 | }; | ||
| 30 | |||
| 31 | const waitForReadyPort = async (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<number> => | ||
| 32 | new Promise((resolveReady, rejectReady) => { | ||
| 33 | const timer = setTimeout( | ||
| 34 | () => rejectReady(new Error("packed binary did not report ready in time")), | ||
| 35 | READY_TIMEOUT_MS, | ||
| 36 | ); | ||
| 37 | let stdout = ""; | ||
| 38 | let stderr = ""; | ||
| 39 | const finish = (result: number | Error): void => { | ||
| 40 | clearTimeout(timer); | ||
| 41 | if (result instanceof Error) rejectReady(result); | ||
| 42 | else resolveReady(result); | ||
| 43 | }; | ||
| 44 | void proc.exited.then((code) => { | ||
| 45 | finish(new Error(`packed binary exited before ready with code ${code}: ${stderr}`)); | ||
| 46 | }); | ||
| 47 | void (async () => { | ||
| 48 | const reader = proc.stdout.getReader(); | ||
| 49 | const decoder = new TextDecoder(); | ||
| 50 | try { | ||
| 51 | while (true) { | ||
| 52 | const chunk = await reader.read(); | ||
| 53 | if (chunk.done) return; | ||
| 54 | stdout += decoder.decode(chunk.value); | ||
| 55 | const match = stdout.match(/EXECUTOR_READY:(\d+)/); | ||
| 56 | if (match) { | ||
| 57 | finish(Number(match[1])); | ||
| 58 | return; | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } catch (cause) { | ||
| 62 | finish(cause instanceof Error ? cause : new Error(String(cause))); | ||
| 63 | } | ||
| 64 | })(); | ||
| 65 | void (async () => { | ||
| 66 | const reader = proc.stderr.getReader(); | ||
| 67 | const decoder = new TextDecoder(); | ||
| 68 | while (true) { | ||
| 69 | const chunk = await reader.read(); | ||
| 70 | if (chunk.done) return; | ||
| 71 | stderr += decoder.decode(chunk.value); | ||
| 72 | } | ||
| 73 | })().catch(() => undefined); | ||
| 74 | }); | ||
| 75 | |||
| 76 | const main = async (): Promise<void> => { | ||
| 77 | const fixture = await mkdtemp(join(tmpdir(), "executor-packed-apps-fixture-")); | ||
| 78 | const dataDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-data-")); | ||
| 79 | const scopeDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-scope-")); | ||
| 80 | let proc: Subprocess<"ignore", "pipe", "pipe"> | null = null; | ||
| 81 | try { | ||
| 82 | await mkdir(join(fixture, "tools"), { recursive: true }); | ||
| 83 | await writeFile( | ||
| 84 | join(fixture, "tools", "greet.ts"), | ||
| 85 | ` | ||
| 86 | import { z } from "zod"; | ||
| 87 | import { defineTool } from "executor:app"; | ||
| 88 | |||
| 89 | const Input = z.object({ name: z.string() }); | ||
| 90 | |||
| 91 | export default defineTool({ | ||
| 92 | description: "Greet", | ||
| 93 | input: Input, | ||
| 94 | async handler(input) { | ||
| 95 | return { greeting: "hello " + input.name }; | ||
| 96 | }, | ||
| 97 | }); | ||
| 98 | `, | ||
| 99 | ); | ||
| 100 | await writeFile( | ||
| 101 | join(fixture, "package.json"), | ||
| 102 | `${JSON.stringify({ name: "packed-apps-smoke", dependencies: { zod: "4.3.6" } })}\n`, | ||
| 103 | ); | ||
| 104 | await writeFile(join(fixture, "bun.lock"), ""); | ||
| 105 | |||
| 106 | proc = spawn({ | ||
| 107 | cmd: [ | ||
| 108 | resolve(binary), | ||
| 109 | "daemon", | ||
| 110 | "run", | ||
| 111 | "--foreground", | ||
| 112 | "--port", | ||
| 113 | "0", | ||
| 114 | "--hostname", | ||
| 115 | "127.0.0.1", | ||
| 116 | "--auth-token", | ||
| 117 | AUTH_TOKEN, | ||
| 118 | ], | ||
| 119 | env: { | ||
| 120 | ...process.env, | ||
| 121 | EXECUTOR_CLIENT: "desktop", | ||
| 122 | EXECUTOR_DATA_DIR: dataDir, | ||
| 123 | EXECUTOR_SCOPE_DIR: scopeDir, | ||
| 124 | }, | ||
| 125 | stdin: "ignore", | ||
| 126 | stdout: "pipe", | ||
| 127 | stderr: "pipe", | ||
| 128 | }); | ||
| 129 | |||
| 130 | const port = await waitForReadyPort(proc); | ||
| 131 | const origin = `http://127.0.0.1:${port}`; | ||
| 132 | console.log(`[smoke-packed-apps] ready on ${origin}`); | ||
| 133 | |||
| 134 | await requestJson(origin, "/api/apps/sources", { | ||
| 135 | method: "POST", | ||
| 136 | body: JSON.stringify({ | ||
| 137 | kind: "local-directory", | ||
| 138 | slug: "packed-apps-smoke", | ||
| 139 | app: "packed-apps-smoke", | ||
| 140 | path: fixture, | ||
| 141 | }), | ||
| 142 | }); | ||
| 143 | |||
| 144 | const sync = await requestJson<{ | ||
| 145 | readonly status: string; | ||
| 146 | readonly tools: readonly string[]; | ||
| 147 | readonly errors?: readonly unknown[]; | ||
| 148 | }>(origin, "/api/apps/sources/packed-apps-smoke/sync", { method: "POST" }); | ||
| 149 | if (sync.status !== "published") fail(`sync failed: ${JSON.stringify(sync.errors)}`); | ||
| 150 | if (JSON.stringify(sync.tools) !== JSON.stringify(["greet"])) { | ||
| 151 | fail(`expected greet tool, got ${JSON.stringify(sync.tools)}`); | ||
| 152 | } | ||
| 153 | |||
| 154 | const invoked = await requestJson<{ | ||
| 155 | readonly status: string; | ||
| 156 | readonly structured: unknown; | ||
| 157 | readonly isError: boolean; | ||
| 158 | readonly text: string; | ||
| 159 | }>(origin, "/api/executions", { | ||
| 160 | method: "POST", | ||
| 161 | body: JSON.stringify({ | ||
| 162 | code: `export default await tools["packed-apps-smoke.org.published.greet"]({ name: "Ada" })`, | ||
| 163 | autoApprove: true, | ||
| 164 | }), | ||
| 165 | }); | ||
| 166 | if (invoked.status !== "completed" || invoked.isError) { | ||
| 167 | fail(`execution failed: ${JSON.stringify(invoked)}`); | ||
| 168 | } | ||
| 169 | if ( | ||
| 170 | JSON.stringify(invoked.structured) !== | ||
| 171 | JSON.stringify({ | ||
| 172 | status: "completed", | ||
| 173 | result: { ok: true, data: { greeting: "hello Ada" } }, | ||
| 174 | logs: [], | ||
| 175 | }) | ||
| 176 | ) { | ||
| 177 | fail(`unexpected execution result: ${JSON.stringify(invoked)}`); | ||
| 178 | } | ||
| 179 | console.log("[smoke-packed-apps] OK - local-directory app source bundled and invoked"); | ||
| 180 | } finally { | ||
| 181 | if (proc) { | ||
| 182 | proc.kill("SIGTERM"); | ||
| 183 | await Promise.race([proc.exited, new Promise((resolve) => setTimeout(resolve, 3000))]); | ||
| 184 | proc.kill("SIGKILL"); | ||
| 185 | } | ||
| 186 | await rm(fixture, { recursive: true, force: true }).catch(() => undefined); | ||
| 187 | await rm(dataDir, { recursive: true, force: true }).catch(() => undefined); | ||
| 188 | await rm(scopeDir, { recursive: true, force: true }).catch(() => undefined); | ||
| 189 | } | ||
| 190 | }; | ||
| 191 | |||
| 192 | await main(); | ||
| @@ -2,7 +2,7 @@ import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises"; · 1 unmodified line | |||
| 2 | import { existsSync } from "node:fs"; | 2 | import { existsSync } from "node:fs"; |
| 3 | import { createRequire } from "node:module"; | 3 | import { createRequire } from "node:module"; |
| 4 | import { resolve, join, dirname } from "node:path"; | 4 | import { resolve, join, dirname } from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; | 5 | import { fileURLToPath, pathToFileURL } from "node:url"; |
| 6 | import { parseArgs } from "node:util"; | 6 | import { parseArgs } from "node:util"; |
| 7 | import { $ } from "bun"; | 7 | import { $ } from "bun"; |
| 8 | 8 | ||
| @@ -11,6 +11,8 @@ const cliRoot = resolve(repoRoot, "apps/cli"); · 2 unmodified lines | |||
| 11 | const webRoot = resolve(repoRoot, "apps/local"); | 11 | const webRoot = resolve(repoRoot, "apps/local"); |
| 12 | const distDir = resolve(cliRoot, "dist"); | 12 | const distDir = resolve(cliRoot, "dist"); |
| 13 | const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm"; | 13 | const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm"; |
| 14 | const WORKERD_VERSION = "1.20260708.1"; | ||
| 15 | const WORKER_BUNDLER_DIRNAME = "worker-bundler"; | ||
| 14 | 16 | ||
| 15 | const resolveQuickJsWasmPath = (): string => { | 17 | const resolveQuickJsWasmPath = (): string => { |
| 16 | const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json")); | 18 | const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json")); |
| @@ -33,6 +35,34 @@ const resolveOnePasswordCoreWasmPath = (): string => { · 16 unmodified lines | |||
| 33 | return wasmPath; | 35 | return wasmPath; |
| 34 | }; | 36 | }; |
| 35 | 37 | ||
| 38 | const resolveWorkerBundlerDistPath = (): string => { | ||
| 39 | const req = createRequire(join(repoRoot, "packages/plugins/apps/package.json")); | ||
| 40 | const pkgJson = req.resolve("@cloudflare/worker-bundler/package.json"); | ||
| 41 | const distPath = join(dirname(pkgJson), "dist"); | ||
| 42 | if (!existsSync(join(distPath, "index.js")) || !existsSync(join(distPath, "esbuild.wasm"))) { | ||
| 43 | throw new Error(`@cloudflare/worker-bundler dist files not found at ${distPath}`); | ||
| 44 | } | ||
| 45 | return distPath; | ||
| 46 | }; | ||
| 47 | |||
| 48 | const createPackedWorkerBundlerSource = async (distPath: string): Promise<string> => { | ||
| 49 | const esbuildPath = join(repoRoot, "node_modules/.bun/node_modules/esbuild/lib/main.js"); | ||
| 50 | if (!existsSync(esbuildPath)) throw new Error(`esbuild not found at ${esbuildPath}`); | ||
| 51 | const { build } = await import(pathToFileURL(esbuildPath).href); | ||
| 52 | const result = await build({ | ||
| 53 | entryPoints: [join(distPath, "index.js")], | ||
| 54 | bundle: true, | ||
| 55 | format: "esm", | ||
| 56 | platform: "browser", | ||
| 57 | external: ["./esbuild.wasm"], | ||
| 58 | logLevel: "silent", | ||
| 59 | write: false, | ||
| 60 | }); | ||
| 61 | const source = result.outputFiles[0]?.text; | ||
| 62 | if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); | ||
| 63 | return source; | ||
| 64 | }; | ||
| 65 | |||
| 36 | // --------------------------------------------------------------------------- | 66 | // --------------------------------------------------------------------------- |
| 37 | // Metadata | 67 | // Metadata |
| 38 | // --------------------------------------------------------------------------- | 68 | // --------------------------------------------------------------------------- |
| @@ -119,6 +149,7 @@ const bunTarget = (t: Target): Bun.Build.CompileTarget => { · 80 unmodified lines | |||
| 119 | }; | 149 | }; |
| 120 | 150 | ||
| 121 | const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor"); | 151 | const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor"); |
| 152 | const workerdBinaryName = (t: Target) => (t.os === "win32" ? "workerd.exe" : "workerd"); | ||
| 122 | 153 | ||
| 123 | const isCurrentPlatform = (t: Target) => | 154 | const isCurrentPlatform = (t: Target) => |
| 124 | t.os === process.platform && t.arch === process.arch && !t.abi; | 155 | t.os === process.platform && t.arch === process.arch && !t.abi; |
| @@ -211,6 +242,41 @@ const resolveLibsqlNative = (t: Target): string | null => { · 86 unmodified lines | |||
| 211 | } | 242 | } |
| 212 | }; | 243 | }; |
| 213 | 244 | ||
| 245 | const resolveWorkerdBinary = (t: Target): string | null => { | ||
| 246 | const platformMap: Record<string, string> = { | ||
| 247 | "darwin-arm64": "@cloudflare/workerd-darwin-arm64", | ||
| 248 | "darwin-x64": "@cloudflare/workerd-darwin-64", | ||
| 249 | "linux-arm64": "@cloudflare/workerd-linux-arm64", | ||
| 250 | "linux-x64": "@cloudflare/workerd-linux-64", | ||
| 251 | "win32-x64": "@cloudflare/workerd-windows-64", | ||
| 252 | }; | ||
| 253 | const key = [t.os, t.arch, t.abi].filter(Boolean).join("-"); | ||
| 254 | const pkg = platformMap[key]; | ||
| 255 | if (!pkg) return null; | ||
| 256 | const binary = workerdBinaryName(t); | ||
| 257 | try { | ||
| 258 | const req = createRequire( | ||
| 259 | join(repoRoot, "packages/kernel/runtime-workerd-subprocess/package.json"), | ||
| 260 | ); | ||
| 261 | const pkgJson = req.resolve(`${pkg}/package.json`); | ||
| 262 | for (const candidate of [ | ||
| 263 | join(dirname(pkgJson), "bin", binary), | ||
| 264 | join(dirname(pkgJson), binary), | ||
| 265 | ]) { | ||
| 266 | if (existsSync(candidate)) return candidate; | ||
| 267 | } | ||
| 268 | } catch { | ||
| 269 | const packageRoot = join( | ||
| 270 | repoRoot, | ||
| 271 | `node_modules/.bun/${pkg.replace("/", "+")}@${WORKERD_VERSION}/node_modules/${pkg}`, | ||
| 272 | ); | ||
| 273 | for (const candidate of [join(packageRoot, "bin", binary), join(packageRoot, binary)]) { | ||
| 274 | if (existsSync(candidate)) return candidate; | ||
| 275 | } | ||
| 276 | } | ||
| 277 | return null; | ||
| 278 | }; | ||
| 279 | |||
| 214 | // --------------------------------------------------------------------------- | 280 | // --------------------------------------------------------------------------- |
| 215 | // Build mode | 281 | // Build mode |
| 216 | // --------------------------------------------------------------------------- | 282 | // --------------------------------------------------------------------------- |
| @@ -333,6 +399,8 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 116 unmodified lines | |||
| 333 | 399 | ||
| 334 | const quickJsWasmPath = resolveQuickJsWasmPath(); | 400 | const quickJsWasmPath = resolveQuickJsWasmPath(); |
| 335 | const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); | 401 | const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); |
| 402 | const workerBundlerDistPath = resolveWorkerBundlerDistPath(); | ||
| 403 | const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath); | ||
| 336 | 404 | ||
| 337 | try { | 405 | try { |
| 338 | for (const target of targets) { | 406 | for (const target of targets) { |
| @@ -375,12 +443,35 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 36 unmodified lines | |||
| 375 | await cp(libsqlNative, join(binDir, "libsql.node")); | 443 | await cp(libsqlNative, join(binDir, "libsql.node")); |
| 376 | } | 444 | } |
| 377 | 445 | ||
| 446 | const workerdBinary = resolveWorkerdBinary(target); | ||
| 447 | if (workerdBinary && existsSync(workerdBinary)) { | ||
| 448 | await cp(workerdBinary, join(binDir, workerdBinaryName(target))); | ||
| 449 | if (target.os !== "win32") await chmod(join(binDir, workerdBinaryName(target)), 0o755); | ||
| 450 | } | ||
| 451 | |||
| 452 | await cp(workerBundlerDistPath, join(binDir, WORKER_BUNDLER_DIRNAME, "dist"), { | ||
| 453 | recursive: true, | ||
| 454 | }); | ||
| 455 | await writeFile( | ||
| 456 | join(binDir, WORKER_BUNDLER_DIRNAME, "dist", "index.bundled.js"), | ||
| 457 | packedWorkerBundlerSource, | ||
| 458 | ); | ||
| 459 | |||
| 378 | // Smoke test on current platform | 460 | // Smoke test on current platform |
| 379 | if (isCurrentPlatform(target)) { | 461 | if (isCurrentPlatform(target)) { |
| 380 | const bin = join(binDir, binaryName(target)); | 462 | const bin = join(binDir, binaryName(target)); |
| 381 | console.log(` Smoke test: ${bin} --version`); | 463 | console.log(` Smoke test: ${bin} --version`); |
| 382 | const version = await $`${bin} --version`.text(); | 464 | const version = await $`${bin} --version`.text(); |
| 383 | console.log(` OK: ${version.trim()}`); | 465 | console.log(` OK: ${version.trim()}`); |
| 466 | console.log(` Smoke test: packed apps source sync and invoke`); | ||
| 467 | const smoke = Bun.spawn( | ||
| 468 | ["bun", "run", join(cliRoot, "scripts/smoke-packed-apps.ts"), bin], | ||
| 469 | { | ||
| 470 | cwd: repoRoot, | ||
| 471 | stdio: ["ignore", "inherit", "inherit"], | ||
| 472 | }, | ||
| 473 | ); | ||
| 474 | if ((await smoke.exited) !== 0) throw new Error("packed apps smoke failed"); | ||
| 384 | } | 475 | } |
| 385 | 476 | ||
| 386 | // Variant package.json. All variants publish to the SAME npm package | 477 | // Variant package.json. All variants publish to the SAME npm package |
| @@ -1,11 +1,10 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Native-binding bootstrap for the `bun build --compile` binary. | 2 | // Native-binding bootstrap for the `bun build --compile` binary. |
| 3 | // | 3 | // |
| 4 | // `bun --compile` bundles JS into bunfs but does NOT include `.node` native | 4 | // `bun --compile` bundles JS into bunfs but does NOT include native binaries, |
| 5 | // addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside | 5 | // wasm sidecars, or runtime-read package files. build.ts copies those artifacts |
| 6 | // the binary fails. build.ts copies each platform's `.node` next to the | 6 | // next to the executable; here we publish their on-disk paths via env vars the |
| 7 | // executable (`libsql.node`, `keyring.node`); here we publish their on-disk | 7 | // loaders read. |
| 8 | // paths via env vars the loaders read. | ||
| 9 | // | 8 | // |
| 10 | // This MUST be the FIRST import in main.ts. ES modules evaluate every import | 9 | // This MUST be the FIRST import in main.ts. ES modules evaluate every import |
| 11 | // before the importer's own body, and libSQL resolves its native addon EAGERLY | 10 | // before the importer's own body, and libSQL resolves its native addon EAGERLY |
| @@ -43,3 +42,18 @@ if ( · 31 unmodified lines | |||
| 43 | ) { | 42 | ) { |
| 44 | process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; | 43 | process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; |
| 45 | } | 44 | } |
| 45 | |||
| 46 | const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd"); | ||
| 47 | if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) { | ||
| 48 | process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk; | ||
| 49 | } | ||
| 50 | |||
| 51 | const workerBundlerOnDisk = join(execDir, "worker-bundler"); | ||
| 52 | if ( | ||
| 53 | typeof Bun !== "undefined" && | ||
| 54 | !process.env.EXECUTOR_WORKER_BUNDLER_DIR && | ||
| 55 | existsSync(join(workerBundlerOnDisk, "dist", "index.js")) && | ||
| 56 | existsSync(join(workerBundlerOnDisk, "dist", "esbuild.wasm")) | ||
| 57 | ) { | ||
| 58 | process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk; | ||
| 59 | } | ||
| @@ -8,7 +8,7 @@ · 7 unmodified lines | |||
| 8 | "skipLibCheck": true, | 8 | "skipLibCheck": true, |
| 9 | "noEmit": true, | 9 | "noEmit": true, |
| 10 | "declaration": true, | 10 | "declaration": true, |
| 11 | "types": ["bun-types"] | 11 | "types": ["bun-types", "node"] |
| 12 | }, | 12 | }, |
| 13 | "include": ["src", "bin"] | 13 | "include": ["src", "bin"] |
| 14 | } | 14 | } |
| @@ -75,6 +75,7 @@ export default defineExecutorConfig({ · 74 unmodified lines | |||
| 75 | workosCredentials, | 75 | workosCredentials, |
| 76 | workosVaultClient, | 76 | workosVaultClient, |
| 77 | activeToolkitSlug, | 77 | activeToolkitSlug, |
| 78 | allowLocalNetwork, | ||
| 78 | workerLoader, | 79 | workerLoader, |
| 79 | }: CloudPluginDeps = {}) => | 80 | }: CloudPluginDeps = {}) => |
| 80 | [ | 81 | [ |
| @@ -94,6 +95,7 @@ export default defineExecutorConfig({ · 13 unmodified lines | |||
| 94 | } | 95 | } |
| 95 | : {}), | 96 | : {}), |
| 96 | sourceKinds: ["git"], | 97 | sourceKinds: ["git"], |
| 98 | allowPrivateGitHosts: allowLocalNetwork === true, | ||
| 97 | }), | 99 | }), |
| 98 | toolkitsPlugin({ activeToolkitSlug }), | 100 | toolkitsPlugin({ activeToolkitSlug }), |
| 99 | workosVaultPlugin({ | 101 | workosVaultPlugin({ |
| @@ -1,11 +1,9 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Native-binding bootstrap for manually compiled sidecar binaries. | 2 | // Native-binding bootstrap for manually compiled sidecar binaries. |
| 3 | // | 3 | // |
| 4 | // `bun --compile` bundles JS into bunfs but does NOT include `.node` native | 4 | // `bun --compile` bundles JS into bunfs but does NOT include native binaries, |
| 5 | // addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside | 5 | // wasm sidecars, or runtime-read package files. If this dev sidecar is compiled |
| 6 | // the binary fails. If this dev sidecar is compiled directly, copy each | 6 | // directly, copy those artifacts next to the executable; here we publish their |
| 7 | // platform's `.node` next to the executable (`libsql.node`, `keyring.node`); | ||
| 8 | // here we publish their | ||
| 9 | // on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts. | 7 | // on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts. |
| 10 | // | 8 | // |
| 11 | // This MUST be the FIRST import in server.ts. ES modules evaluate every import | 9 | // This MUST be the FIRST import in server.ts. ES modules evaluate every import |
| @@ -44,3 +42,18 @@ if ( · 32 unmodified lines | |||
| 44 | ) { | 42 | ) { |
| 45 | process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; | 43 | process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; |
| 46 | } | 44 | } |
| 45 | |||
| 46 | const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd"); | ||
| 47 | if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) { | ||
| 48 | process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk; | ||
| 49 | } | ||
| 50 | |||
| 51 | const workerBundlerOnDisk = join(execDir, "worker-bundler"); | ||
| 52 | if ( | ||
| 53 | typeof Bun !== "undefined" && | ||
| 54 | !process.env.EXECUTOR_WORKER_BUNDLER_DIR && | ||
| 55 | existsSync(join(workerBundlerOnDisk, "dist", "index.js")) && | ||
| 56 | existsSync(join(workerBundlerOnDisk, "dist", "esbuild.wasm")) | ||
| 57 | ) { | ||
| 58 | process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk; | ||
| 59 | } | ||
| @@ -32,10 +32,14 @@ import { resolveSecretKey } from "./src/config"; · 31 unmodified lines | |||
| 32 | 32 | ||
| 33 | interface SelfHostPluginDeps { | 33 | interface SelfHostPluginDeps { |
| 34 | readonly activeToolkitSlug?: string; | 34 | readonly activeToolkitSlug?: string; |
| 35 | readonly sourceKinds?: readonly ("git" | "local-directory")[]; | ||
| 36 | /** Accepted for test-harness parity; the Microsoft Graph URL override moved | ||
| 37 | * into the OpenAPI provider presets, so the factory no longer reads it. */ | ||
| 38 | readonly allowLocalNetwork?: boolean; | ||
| 35 | } | 39 | } |
| 36 | 40 | ||
| 37 | export default defineExecutorConfig({ | 41 | export default defineExecutorConfig({ |
| 38 | plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) => | 42 | plugins: ({ activeToolkitSlug, sourceKinds }: SelfHostPluginDeps = {}) => |
| 39 | [ | 43 | [ |
| 40 | openApiHttpPlugin({ | 44 | openApiHttpPlugin({ |
| 41 | presets: [...googleCatalog, ...microsoftCatalog], | 45 | presets: [...googleCatalog, ...microsoftCatalog], |
| @@ -46,7 +50,7 @@ export default defineExecutorConfig({ · 4 unmodified lines | |||
| 46 | appsHttpPlugin({ | 50 | appsHttpPlugin({ |
| 47 | executor: makeWorkerdAppToolExecutor(), | 51 | executor: makeWorkerdAppToolExecutor(), |
| 48 | bundler: makeWorkerBundlerBackend(), | 52 | bundler: makeWorkerBundlerBackend(), |
| 49 | sourceKinds: ["git", "local-directory"], | 53 | sourceKinds: sourceKinds ?? ["git"], |
| 50 | allowPrivateGitHosts: true, | 54 | allowPrivateGitHosts: true, |
| 51 | }), | 55 | }), |
| 52 | toolkitsPlugin({ activeToolkitSlug }), | 56 | toolkitsPlugin({ activeToolkitSlug }), |
| @@ -98,7 +98,36 @@ test("POST /executions runs code in the QuickJS sandbox", async () => { · 97 unmodified lines | |||
| 98 | expect(body.isError).toBe(false); | 98 | expect(body.isError).toBe(false); |
| 99 | }); | 99 | }); |
| 100 | 100 | ||
| 101 | test("selfhost rejects local-directory app sources over HTTP", async () => { | ||
| 102 | const created = await handler( | ||
| 103 | new Request("http://localhost/api/apps/sources", { | ||
| 104 | method: "POST", | ||
| 105 | headers: { "content-type": "application/json" }, | ||
| 106 | body: JSON.stringify({ | ||
| 107 | kind: "local-directory", | ||
| 108 | slug: "local-greeter-disabled", | ||
| 109 | app: "local-greeter-disabled", | ||
| 110 | path: "/tmp/local-greeter-disabled", | ||
| 111 | }), | ||
| 112 | }), | ||
| 113 | ); | ||
| 114 | expect(created.status).toBe(500); | ||
| 115 | |||
| 116 | const dirs = await handler(new Request("http://localhost/api/apps/fs/dirs")); | ||
| 117 | expect(dirs.status).toBe(404); | ||
| 118 | }); | ||
| 119 | |||
| 101 | test("apps source sync publishes and invokes a local-directory tool over HTTP", async () => { | 120 | test("apps source sync publishes and invokes a local-directory tool over HTTP", async () => { |
| 121 | const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app"); | ||
| 122 | const localDirectoryApp = await makeSelfHostTestApp({ | ||
| 123 | identity: singleAdminIdentityLayer({ | ||
| 124 | userId: "admin-local-directory", | ||
| 125 | organizationId: "default-org-local-directory", | ||
| 126 | organizationName: "Default Local Directory", | ||
| 127 | }), | ||
| 128 | // This test exercises the plugin loop, not the selfhost product posture. | ||
| 129 | pluginDeps: { sourceKinds: ["git", "local-directory"] }, | ||
| 130 | }); | ||
| 102 | const root = mkdtempSync(join(tmpdir(), "eh-app-src-")); | 131 | const root = mkdtempSync(join(tmpdir(), "eh-app-src-")); |
| 103 | mkdirSync(join(root, "tools"), { recursive: true }); | 132 | mkdirSync(join(root, "tools"), { recursive: true }); |
| 104 | writeFileSync( | 133 | writeFileSync( |
| @@ -121,51 +150,56 @@ test("apps source sync publishes and invokes a local-directory tool over HTTP", · 16 unmodified lines | |||
| 121 | ); | 150 | ); |
| 122 | writeFileSync(join(root, "package.json"), JSON.stringify({ name: "local-greeter" })); | 151 | writeFileSync(join(root, "package.json"), JSON.stringify({ name: "local-greeter" })); |
| 123 | 152 | ||
| 124 | const created = await handler( | 153 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test fixture must dispose the booted app after assertions |
| 125 | new Request("http://localhost/api/apps/sources", { | 154 | try { |
| 126 | method: "POST", | 155 | const created = await localDirectoryApp.handler( |
| 127 | headers: { "content-type": "application/json" }, | 156 | new Request("http://localhost/api/apps/sources", { |
| 128 | body: JSON.stringify({ | 157 | method: "POST", |
| 129 | kind: "local-directory", | 158 | headers: { "content-type": "application/json" }, |
| 130 | slug: "local-greeter", | 159 | body: JSON.stringify({ |
| 131 | app: "local-greeter", | 160 | kind: "local-directory", |
| 132 | path: root, | 161 | slug: "local-greeter", |
| 162 | app: "local-greeter", | ||
| 163 | path: root, | ||
| 164 | }), | ||
| 133 | }), | 165 | }), |
| 134 | }), | 166 | ); |
| 135 | ); | 167 | expect(created.status).toBe(200); |
| 136 | expect(created.status).toBe(200); | ||
| 137 | 168 | ||
| 138 | const synced = await handler( | 169 | const synced = await localDirectoryApp.handler( |
| 139 | new Request("http://localhost/api/apps/sources/local-greeter/sync", { method: "POST" }), | 170 | new Request("http://localhost/api/apps/sources/local-greeter/sync", { method: "POST" }), |
| 140 | ); | 171 | ); |
| 141 | expect(synced.status).toBe(200); | 172 | expect(synced.status).toBe(200); |
| 142 | const syncBody = (await synced.json()) as { | 173 | const syncBody = (await synced.json()) as { |
| 143 | readonly status: string; | 174 | readonly status: string; |
| 144 | readonly tools: readonly string[]; | 175 | readonly tools: readonly string[]; |
| 145 | readonly errors?: readonly unknown[]; | 176 | readonly errors?: readonly unknown[]; |
| 146 | }; | 177 | }; |
| 147 | expect(syncBody.status, JSON.stringify(syncBody.errors)).toBe("published"); | 178 | expect(syncBody.status, JSON.stringify(syncBody.errors)).toBe("published"); |
| 148 | expect(syncBody.tools).toEqual(["greeter"]); | 179 | expect(syncBody.tools).toEqual(["greeter"]); |
| 149 | 180 | ||
| 150 | const listed = await handler(new Request("http://localhost/api/tools")); | 181 | const listed = await localDirectoryApp.handler(new Request("http://localhost/api/tools")); |
| 151 | expect(listed.status).toBe(200); | 182 | expect(listed.status).toBe(200); |
| 152 | const tools = (await listed.json()) as ReadonlyArray<{ readonly address: string }>; | 183 | const tools = (await listed.json()) as ReadonlyArray<{ readonly address: string }>; |
| 153 | expect( | 184 | expect( |
| 154 | tools.some((tool) => tool.address === "tools.apps.org.published.greeter"), | 185 | tools.some((tool) => tool.address === "tools.local-greeter.org.published.greeter"), |
| 155 | JSON.stringify(tools.map((tool) => tool.address)), | 186 | JSON.stringify(tools.map((tool) => tool.address)), |
| 156 | ).toBe(true); | 187 | ).toBe(true); |
| 157 | 188 | ||
| 158 | const invoked = await handler( | 189 | const invoked = await localDirectoryApp.handler( |
| 159 | new Request("http://localhost/api/executions", { | 190 | new Request("http://localhost/api/executions", { |
| 160 | method: "POST", | 191 | method: "POST", |
| 161 | headers: { "content-type": "application/json" }, | 192 | headers: { "content-type": "application/json" }, |
| 162 | body: JSON.stringify({ | 193 | body: JSON.stringify({ |
| 163 | code: 'export default await tools.apps.org.published.greeter({ name: "Ada" })', | 194 | code: 'export default await tools["local-greeter.org.published.greeter"]({ name: "Ada" })', |
| 195 | }), | ||
| 164 | }), | 196 | }), |
| 165 | }), | 197 | ); |
| 166 | ); | 198 | expect(invoked.status).toBe(200); |
| 167 | expect(invoked.status).toBe(200); | 199 | const output = (await invoked.json()) as { readonly text: string; readonly isError: boolean }; |
| 168 | const output = (await invoked.json()) as { readonly text: string; readonly isError: boolean }; | 200 | expect(output.isError).toBe(false); |
| 169 | expect(output.isError).toBe(false); | 201 | expect(output.text).toContain("hello Ada"); |
| 170 | expect(output.text).toContain("hello Ada"); | 202 | } finally { |
| 203 | await localDirectoryApp.dispose(); | ||
| 204 | } | ||
| 171 | }); | 205 | }); |
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | |||
| 5 | composePluginApi, | 5 | composePluginApi, |
| 6 | ExecutorApp, | 6 | ExecutorApp, |
| 7 | IdentityProvider, | 7 | IdentityProvider, |
| 8 | PluginsProvider, | ||
| 8 | type Principal, | 9 | type Principal, |
| 9 | textFailureStrategy, | 10 | textFailureStrategy, |
| 10 | Unauthorized, | 11 | Unauthorized, |
| @@ -22,6 +23,7 @@ import { · 11 unmodified lines | |||
| 22 | SelfHostHostConfig, | 23 | SelfHostHostConfig, |
| 23 | SelfHostPluginsProvider, | 24 | SelfHostPluginsProvider, |
| 24 | } from "../execution"; | 25 | } from "../execution"; |
| 26 | import executorConfig from "../../executor.config"; | ||
| 25 | import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; | 27 | import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; |
| 26 | import { | 28 | import { |
| 27 | makeSelfHostMcpSessionStore, | 29 | makeSelfHostMcpSessionStore, |
| @@ -165,6 +167,8 @@ export interface MakeSelfHostTestAppOptions { · 137 unmodified lines | |||
| 165 | readonly identity: Layer.Layer<IdentityProvider>; | 167 | readonly identity: Layer.Layer<IdentityProvider>; |
| 166 | /** Override the SQLite path (defaults to the config data dir). */ | 168 | /** Override the SQLite path (defaults to the config data dir). */ |
| 167 | readonly dbPath?: string; | 169 | readonly dbPath?: string; |
| 170 | /** Test-only override for executor.config.ts plugin dependencies. */ | ||
| 171 | readonly pluginDeps?: Parameters<typeof executorConfig.plugins>[0]; | ||
| 168 | } | 172 | } |
| 169 | 173 | ||
| 170 | export interface SelfHostTestHandler { | 174 | export interface SelfHostTestHandler { |
| @@ -185,6 +189,21 @@ export const makeSelfHostTestApp = async ( · 14 unmodified lines | |||
| 185 | }); | 189 | }); |
| 186 | 190 | ||
| 187 | const sessionStore = makeSelfHostMcpSessionStore(dbHandle); | 191 | const sessionStore = makeSelfHostMcpSessionStore(dbHandle); |
| 192 | const pluginsProvider = options.pluginDeps | ||
| 193 | ? Layer.succeed(PluginsProvider)({ | ||
| 194 | plugins: (context) => | ||
| 195 | executorConfig.plugins({ | ||
| 196 | ...options.pluginDeps, | ||
| 197 | activeToolkitSlug: | ||
| 198 | context?.mcpResource?.kind === "toolkit" | ||
| 199 | ? context.mcpResource.slug | ||
| 200 | : options.pluginDeps?.activeToolkitSlug, | ||
| 201 | allowLocalNetwork: | ||
| 202 | options.pluginDeps?.allowLocalNetwork ?? | ||
| 203 | process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", | ||
| 204 | }), | ||
| 205 | }) | ||
| 206 | : SelfHostPluginsProvider; | ||
| 188 | 207 | ||
| 189 | const { toWebHandler } = ExecutorApp.make({ | 208 | const { toWebHandler } = ExecutorApp.make({ |
| 190 | plugins: selfHostPlugins, | 209 | plugins: selfHostPlugins, |
| @@ -197,7 +216,7 @@ export const makeSelfHostTestApp = async ( · 6 unmodified lines | |||
| 197 | sessions: selfHostMcpSessions(sessionStore), | 216 | sessions: selfHostMcpSessions(sessionStore), |
| 198 | reporter: selfHostMcpReporter, | 217 | reporter: selfHostMcpReporter, |
| 199 | }, | 218 | }, |
| 200 | plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig }, | 219 | plugins: { provider: pluginsProvider, config: SelfHostHostConfig }, |
| 201 | errorCapture: ErrorCaptureLive, | 220 | errorCapture: ErrorCaptureLive, |
| 202 | }, | 221 | }, |
| 203 | extensions: { | 222 | extensions: { |
| @@ -1,4 +1,9 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { | ||
| 3 | makeWorkerBundlerBackend, | ||
| 4 | makeWorkerdAppToolExecutor, | ||
| 5 | } from "@executor-js/plugin-apps/selfhost"; | ||
| 6 | import { appsHttpPlugin } from "@executor-js/plugin-apps/api"; | ||
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 7 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { | 8 | import { |
| 4 | googleCatalog, | 9 | googleCatalog, |
| @@ -38,6 +43,12 @@ export default defineExecutorConfig({ · 33 unmodified lines | |||
| 38 | }), | 43 | }), |
| 39 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), | 44 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), |
| 40 | graphqlHttpPlugin(), | 45 | graphqlHttpPlugin(), |
| 46 | appsHttpPlugin({ | ||
| 47 | executor: makeWorkerdAppToolExecutor(), | ||
| 48 | bundler: makeWorkerBundlerBackend(), | ||
| 49 | sourceKinds: ["git", "local-directory"], | ||
| 50 | allowPrivateGitHosts: true, | ||
| 51 | }), | ||
| 41 | toolkitsPlugin({ activeToolkitSlug }), | 52 | toolkitsPlugin({ activeToolkitSlug }), |
| 42 | keychainPlugin(), | 53 | keychainPlugin(), |
| 43 | fileSecretsPlugin(), | 54 | fileSecretsPlugin(), |
| @@ -29,6 +29,7 @@ · 28 unmodified lines | |||
| 29 | "@executor-js/fumadb": "workspace:*", | 29 | "@executor-js/fumadb": "workspace:*", |
| 30 | "@executor-js/host-mcp": "workspace:*", | 30 | "@executor-js/host-mcp": "workspace:*", |
| 31 | "@executor-js/integrations-registry": "workspace:*", | 31 | "@executor-js/integrations-registry": "workspace:*", |
| 32 | "@executor-js/plugin-apps": "workspace:*", | ||
| 32 | "@executor-js/plugin-desktop-settings": "workspace:*", | 33 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| 33 | "@executor-js/plugin-example": "workspace:*", | 34 | "@executor-js/plugin-example": "workspace:*", |
| 34 | "@executor-js/plugin-file-secrets": "workspace:*", | 35 | "@executor-js/plugin-file-secrets": "workspace:*", |
| @@ -50,6 +50,7 @@ · 49 unmodified lines | |||
| 50 | }, | 50 | }, |
| 51 | "devDependencies": { | 51 | "devDependencies": { |
| 52 | "@effect/vitest": "catalog:", | 52 | "@effect/vitest": "catalog:", |
| 53 | "@types/node": "catalog:", | ||
| 53 | "bun-types": "catalog:", | 54 | "bun-types": "catalog:", |
| 54 | "typescript": "catalog:", | 55 | "typescript": "catalog:", |
| 55 | "vitest": "catalog:", | 56 | "vitest": "catalog:", |
| @@ -270,6 +271,7 @@ · 214 unmodified lines | |||
| 270 | "@executor-js/fumadb": "workspace:*", | 271 | "@executor-js/fumadb": "workspace:*", |
| 271 | "@executor-js/host-mcp": "workspace:*", | 272 | "@executor-js/host-mcp": "workspace:*", |
| 272 | "@executor-js/integrations-registry": "workspace:*", | 273 | "@executor-js/integrations-registry": "workspace:*", |
| 274 | "@executor-js/plugin-apps": "workspace:*", | ||
| 273 | "@executor-js/plugin-desktop-settings": "workspace:*", | 275 | "@executor-js/plugin-desktop-settings": "workspace:*", |
| 274 | "@executor-js/plugin-example": "workspace:*", | 276 | "@executor-js/plugin-example": "workspace:*", |
| 275 | "@executor-js/plugin-file-secrets": "workspace:*", | 277 | "@executor-js/plugin-file-secrets": "workspace:*", |
| @@ -801,16 +803,30 @@ · 525 unmodified lines | |||
| 801 | "zod": "4.3.6", | 803 | "zod": "4.3.6", |
| 802 | }, | 804 | }, |
| 803 | "devDependencies": { | 805 | "devDependencies": { |
| 806 | "@effect/atom-react": "catalog:", | ||
| 804 | "@effect/vitest": "catalog:", | 807 | "@effect/vitest": "catalog:", |
| 808 | "@executor-js/react": "workspace:*", | ||
| 805 | "@types/node": "catalog:", | 809 | "@types/node": "catalog:", |
| 810 | "@types/react": "catalog:", | ||
| 806 | "bun-types": "catalog:", | 811 | "bun-types": "catalog:", |
| 807 | "effect": "catalog:", | 812 | "effect": "catalog:", |
| 813 | "react": "catalog:", | ||
| 808 | "tsup": "catalog:", | 814 | "tsup": "catalog:", |
| 809 | "vitest": "catalog:", | 815 | "vitest": "catalog:", |
| 810 | }, | 816 | }, |
| 811 | "peerDependencies": { | 817 | "peerDependencies": { |
| 818 | "@effect/atom-react": "catalog:", | ||
| 819 | "@executor-js/react": "workspace:*", | ||
| 820 | "@tanstack/react-router": "catalog:", | ||
| 812 | "effect": "catalog:", | 821 | "effect": "catalog:", |
| 822 | "react": "catalog:", | ||
| 813 | }, | 823 | }, |
| 824 | "optionalPeers": [ | ||
| 825 | "@effect/atom-react", | ||
| 826 | "@executor-js/react", | ||
| 827 | "@tanstack/react-router", | ||
| 828 | "react", | ||
| 829 | ], | ||
| 814 | }, | 830 | }, |
| 815 | "packages/plugins/desktop-settings": { | 831 | "packages/plugins/desktop-settings": { |
| 816 | "name": "@executor-js/plugin-desktop-settings", | 832 | "name": "@executor-js/plugin-desktop-settings", |
| @@ -0,0 +1,264 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e SSH and raw HTTP assertions */ | ||
| 2 | // Proves the compiled executor binary's bundled workerd + worker-bundler path | ||
| 3 | // can package local-directory custom tools on every CLI guest OS. The sync | ||
| 4 | // assertion covers npm install inside workerd, including the Windows TLS trust | ||
| 5 | // path fixed by f0365449, and the execution assertion proves the published tool | ||
| 6 | // is invoked, not just synced. | ||
| 7 | import { execFile } from "node:child_process"; | ||
| 8 | import { promisify } from "node:util"; | ||
| 9 | |||
| 10 | import { expect } from "@effect/vitest"; | ||
| 11 | import { Effect } from "effect"; | ||
| 12 | |||
| 13 | import { scenario } from "../src/scenario"; | ||
| 14 | import { Target } from "../src/services"; | ||
| 15 | import type { Identity, Target as TargetShape } from "../src/target"; | ||
| 16 | |||
| 17 | const execFileAsync = promisify(execFile); | ||
| 18 | |||
| 19 | const SSH_OPTS = [ | ||
| 20 | "-o", | ||
| 21 | "StrictHostKeyChecking=no", | ||
| 22 | "-o", | ||
| 23 | "UserKnownHostsFile=/dev/null", | ||
| 24 | "-o", | ||
| 25 | "ConnectTimeout=15", | ||
| 26 | "-o", | ||
| 27 | "ServerAliveInterval=5", | ||
| 28 | "-o", | ||
| 29 | "LogLevel=ERROR", | ||
| 30 | ] as const; | ||
| 31 | |||
| 32 | type GuestOs = "macos" | "linux" | "windows"; | ||
| 33 | |||
| 34 | interface SyncResponse { | ||
| 35 | readonly status: "published" | "up-to-date" | "failed"; | ||
| 36 | readonly tools: readonly string[]; | ||
| 37 | } | ||
| 38 | |||
| 39 | interface ExecuteResponse { | ||
| 40 | readonly structured: unknown; | ||
| 41 | readonly text: string; | ||
| 42 | } | ||
| 43 | |||
| 44 | const guestOs = (): GuestOs => { | ||
| 45 | const os = process.env.E2E_VM_OS; | ||
| 46 | if (os === "macos" || os === "linux" || os === "windows") return os; | ||
| 47 | throw new Error(`Unsupported E2E_VM_OS: ${os ?? "<unset>"}`); | ||
| 48 | }; | ||
| 49 | |||
| 50 | const sshInvocation = (command: string): { command: string; args: ReadonlyArray<string> } => { | ||
| 51 | const host = process.env.E2E_CLI_VM_HOST; | ||
| 52 | if (!host) throw new Error("E2E_CLI_VM_HOST is not set"); | ||
| 53 | const os = guestOs(); | ||
| 54 | const wrapped = | ||
| 55 | os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command; | ||
| 56 | const keyPath = process.env.E2E_CLI_SSH_KEY; | ||
| 57 | const user = os === "windows" ? "Administrator" : "admin"; | ||
| 58 | return keyPath | ||
| 59 | ? { command: "ssh", args: ["-i", keyPath, ...SSH_OPTS, `${user}@${host}`, wrapped] } | ||
| 60 | : { | ||
| 61 | command: process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass", | ||
| 62 | args: ["-p", "admin", "ssh", ...SSH_OPTS, `${user}@${host}`, wrapped], | ||
| 63 | }; | ||
| 64 | }; | ||
| 65 | |||
| 66 | const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => { | ||
| 67 | const invocation = sshInvocation(command); | ||
| 68 | try { | ||
| 69 | const { stdout, stderr } = await execFileAsync(invocation.command, [...invocation.args], { | ||
| 70 | maxBuffer: 64 * 1024 * 1024, | ||
| 71 | }); | ||
| 72 | return { stdout, stderr, code: 0 }; | ||
| 73 | } catch (error) { | ||
| 74 | const err = error as { stdout?: string; stderr?: string; code?: number }; | ||
| 75 | return { | ||
| 76 | stdout: err.stdout ?? "", | ||
| 77 | stderr: err.stderr ?? "", | ||
| 78 | code: typeof err.code === "number" ? err.code : 1, | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | }; | ||
| 82 | |||
| 83 | const fixturePath = (slug: string): string => | ||
| 84 | guestOs() === "windows" | ||
| 85 | ? `C:/Users/Administrator/apps-packed-fixture-${slug}` | ||
| 86 | : `/tmp/apps-packed-fixture-${slug}`; | ||
| 87 | |||
| 88 | const windowsFixtureCommand = (path: string): string => ` | ||
| 89 | $ErrorActionPreference = 'Stop' | ||
| 90 | Remove-Item -Recurse -Force '${path}' -ErrorAction SilentlyContinue | ||
| 91 | New-Item -ItemType Directory -Force -Path '${path}/tools' | Out-Null | ||
| 92 | Set-Content -NoNewline -Encoding UTF8 -Path '${path}/tools/greet.ts' -Value @' | ||
| 93 | ${"import"} { z } from "zod"; | ||
| 94 | ${"import"} { defineTool } from "executor:app"; | ||
| 95 | |||
| 96 | const Input = z.object({ name: z.string() }); | ||
| 97 | |||
| 98 | export default defineTool({ | ||
| 99 | description: "Greet", | ||
| 100 | input: Input, | ||
| 101 | async handler(input) { | ||
| 102 | return { greeting: "hello " + input.name }; | ||
| 103 | }, | ||
| 104 | }); | ||
| 105 | '@ | ||
| 106 | Set-Content -NoNewline -Encoding UTF8 -Path '${path}/package.json' -Value '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' | ||
| 107 | Set-Content -NoNewline -Encoding UTF8 -Path '${path}/bun.lock' -Value '' | ||
| 108 | `; | ||
| 109 | |||
| 110 | const unixFixtureCommand = (path: string): string => ` | ||
| 111 | set -eu | ||
| 112 | rm -rf '${path}' | ||
| 113 | mkdir -p '${path}/tools' | ||
| 114 | cat >'${path}/tools/greet.ts' <<'EOF' | ||
| 115 | ${"import"} { z } from "zod"; | ||
| 116 | ${"import"} { defineTool } from "executor:app"; | ||
| 117 | |||
| 118 | const Input = z.object({ name: z.string() }); | ||
| 119 | |||
| 120 | export default defineTool({ | ||
| 121 | description: "Greet", | ||
| 122 | input: Input, | ||
| 123 | async handler(input) { | ||
| 124 | return { greeting: "hello " + input.name }; | ||
| 125 | }, | ||
| 126 | }); | ||
| 127 | EOF | ||
| 128 | printf '%s' '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' >'${path}/package.json' | ||
| 129 | : >'${path}/bun.lock' | ||
| 130 | `; | ||
| 131 | |||
| 132 | const writeFixture = async (path: string): Promise<void> => { | ||
| 133 | const result = await ssh( | ||
| 134 | guestOs() === "windows" ? windowsFixtureCommand(path) : unixFixtureCommand(path), | ||
| 135 | ); | ||
| 136 | expect( | ||
| 137 | result.code, | ||
| 138 | `fixture is written into the guest\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, | ||
| 139 | ).toBe(0); | ||
| 140 | }; | ||
| 141 | |||
| 142 | const removeFixture = (path: string): Effect.Effect<void> => | ||
| 143 | Effect.promise(() => | ||
| 144 | ssh( | ||
| 145 | guestOs() === "windows" | ||
| 146 | ? `Remove-Item -Recurse -Force '${path}' -ErrorAction SilentlyContinue` | ||
| 147 | : `rm -rf '${path}'`, | ||
| 148 | ).then(() => undefined), | ||
| 149 | ).pipe(Effect.ignore); | ||
| 150 | |||
| 151 | const request = async <T>( | ||
| 152 | target: TargetShape, | ||
| 153 | identity: Identity, | ||
| 154 | path: string, | ||
| 155 | init: RequestInit = {}, | ||
| 156 | expectedStatus = 200, | ||
| 157 | timeoutMs = 30_000, | ||
| 158 | ): Promise<T> => { | ||
| 159 | const controller = new AbortController(); | ||
| 160 | const timeout = setTimeout(() => controller.abort(), timeoutMs); | ||
| 161 | try { | ||
| 162 | const headers = new Headers(init.headers); | ||
| 163 | for (const [name, value] of Object.entries(identity.headers ?? {})) headers.set(name, value); | ||
| 164 | if (init.body !== undefined && !headers.has("content-type")) { | ||
| 165 | headers.set("content-type", "application/json"); | ||
| 166 | } | ||
| 167 | const response = await fetch(new URL(path, target.baseUrl), { | ||
| 168 | ...init, | ||
| 169 | headers, | ||
| 170 | signal: controller.signal, | ||
| 171 | }); | ||
| 172 | const text = await response.text(); | ||
| 173 | expect(response.status, `${init.method ?? "GET"} ${path}: ${text}`).toBe(expectedStatus); | ||
| 174 | return (text.length > 0 ? JSON.parse(text) : null) as T; | ||
| 175 | } finally { | ||
| 176 | clearTimeout(timeout); | ||
| 177 | } | ||
| 178 | }; | ||
| 179 | |||
| 180 | const postJson = <T>( | ||
| 181 | target: TargetShape, | ||
| 182 | identity: Identity, | ||
| 183 | path: string, | ||
| 184 | body?: unknown, | ||
| 185 | timeoutMs?: number, | ||
| 186 | ): Promise<T> => | ||
| 187 | request<T>( | ||
| 188 | target, | ||
| 189 | identity, | ||
| 190 | path, | ||
| 191 | { | ||
| 192 | method: "POST", | ||
| 193 | ...(body !== undefined ? { body: JSON.stringify(body) } : {}), | ||
| 194 | }, | ||
| 195 | 200, | ||
| 196 | timeoutMs, | ||
| 197 | ); | ||
| 198 | |||
| 199 | const deleteSource = (target: TargetShape, identity: Identity, slug: string): Effect.Effect<void> => | ||
| 200 | Effect.promise(() => | ||
| 201 | request(target, identity, `/api/apps/sources/${slug}`, { method: "DELETE" }).then( | ||
| 202 | () => undefined, | ||
| 203 | ), | ||
| 204 | ).pipe(Effect.ignore); | ||
| 205 | |||
| 206 | const syncAndInvoke = (input: { | ||
| 207 | readonly target: TargetShape; | ||
| 208 | readonly identity: Identity; | ||
| 209 | readonly slug: string; | ||
| 210 | readonly path: string; | ||
| 211 | }): Effect.Effect<void> => | ||
| 212 | Effect.promise(async () => { | ||
| 213 | await postJson(input.target, input.identity, "/api/apps/sources", { | ||
| 214 | kind: "local-directory", | ||
| 215 | slug: input.slug, | ||
| 216 | app: input.slug, | ||
| 217 | path: input.path, | ||
| 218 | }); | ||
| 219 | |||
| 220 | const sync = await postJson<SyncResponse>( | ||
| 221 | input.target, | ||
| 222 | input.identity, | ||
| 223 | `/api/apps/sources/${input.slug}/sync`, | ||
| 224 | undefined, | ||
| 225 | 180_000, | ||
| 226 | ); | ||
| 227 | expect(sync.status, "the packed binary bundled and published the tool").toBe("published"); | ||
| 228 | expect(sync.tools, "the packed binary bundled and published the tool").toContain("greet"); | ||
| 229 | |||
| 230 | const execution = await postJson<ExecuteResponse>( | ||
| 231 | input.target, | ||
| 232 | input.identity, | ||
| 233 | "/api/executions", | ||
| 234 | { | ||
| 235 | code: `export default await tools["${input.slug}.org.published.greet"]({ name: "Ada" })`, | ||
| 236 | autoApprove: true, | ||
| 237 | }, | ||
| 238 | ); | ||
| 239 | expect( | ||
| 240 | JSON.stringify(execution.structured ?? execution.text), | ||
| 241 | "the published tool executes and returns the greeting", | ||
| 242 | ).toContain('"greeting":"hello Ada"'); | ||
| 243 | }); | ||
| 244 | |||
| 245 | scenario( | ||
| 246 | "CLI packed binary · local-directory custom tool publishes and executes", | ||
| 247 | { timeout: 300_000 }, | ||
| 248 | Effect.gen(function* () { | ||
| 249 | const target = yield* Target; | ||
| 250 | const identity = yield* target.newIdentity(); | ||
| 251 | const slug = `packed-smoke-${crypto.randomUUID().slice(0, 8)}`; | ||
| 252 | const path = fixturePath(slug); | ||
| 253 | |||
| 254 | yield* Effect.promise(() => writeFixture(path)).pipe( | ||
| 255 | Effect.andThen(syncAndInvoke({ target, identity, slug, path })), | ||
| 256 | Effect.ensuring( | ||
| 257 | Effect.all([deleteSource(target, identity, slug), removeFixture(path)], { | ||
| 258 | concurrency: "unbounded", | ||
| 259 | discard: true, | ||
| 260 | }), | ||
| 261 | ), | ||
| 262 | ); | ||
| 263 | }), | ||
| 264 | ); | ||
| @@ -0,0 +1,228 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e SSH and raw HTTP assertions */ | ||
| 2 | // Proves the packaged desktop bundle's sidecar daemon can package and invoke a | ||
| 3 | // local-directory custom tool from inside the GUI guest. The API calls run over | ||
| 4 | // guest loopback so the daemon reads the same guest filesystem as the fixture. | ||
| 5 | import { execFile } from "node:child_process"; | ||
| 6 | import { promisify } from "node:util"; | ||
| 7 | |||
| 8 | import { expect, it } from "@effect/vitest"; | ||
| 9 | import { Effect } from "effect"; | ||
| 10 | |||
| 11 | import { scenario } from "../src/scenario"; | ||
| 12 | |||
| 13 | const execFileAsync = promisify(execFile); | ||
| 14 | |||
| 15 | const SSH_OPTS = [ | ||
| 16 | "-o", | ||
| 17 | "StrictHostKeyChecking=no", | ||
| 18 | "-o", | ||
| 19 | "UserKnownHostsFile=/dev/null", | ||
| 20 | "-o", | ||
| 21 | "ConnectTimeout=15", | ||
| 22 | "-o", | ||
| 23 | "ServerAliveInterval=5", | ||
| 24 | "-o", | ||
| 25 | "LogLevel=ERROR", | ||
| 26 | ] as const; | ||
| 27 | |||
| 28 | type DesktopOs = "macos" | "linux" | "windows"; | ||
| 29 | |||
| 30 | interface CurlResult<T> { | ||
| 31 | readonly status: number; | ||
| 32 | readonly body: T; | ||
| 33 | readonly text: string; | ||
| 34 | } | ||
| 35 | |||
| 36 | interface SyncResponse { | ||
| 37 | readonly status: "published" | "up-to-date" | "failed"; | ||
| 38 | readonly tools: readonly string[]; | ||
| 39 | } | ||
| 40 | |||
| 41 | interface ExecuteResponse { | ||
| 42 | readonly structured: unknown; | ||
| 43 | readonly text: string; | ||
| 44 | } | ||
| 45 | |||
| 46 | const NAME = "Desktop sidecar · local-directory custom tool publishes and executes"; | ||
| 47 | const guestIp = process.env.E2E_DESKTOP_VM_IP; | ||
| 48 | const os: DesktopOs = | ||
| 49 | process.env.E2E_TARGET === "desktop-windows" | ||
| 50 | ? "windows" | ||
| 51 | : process.env.E2E_TARGET === "desktop-linux" | ||
| 52 | ? "linux" | ||
| 53 | : "macos"; | ||
| 54 | |||
| 55 | const token = (): string => (os === "linux" ? "desktop-linux-e2e" : "desktop-macos-e2e"); | ||
| 56 | |||
| 57 | const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => { | ||
| 58 | if (!guestIp) throw new Error("E2E_DESKTOP_VM_IP is not set"); | ||
| 59 | const wrapped = | ||
| 60 | os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command; | ||
| 61 | try { | ||
| 62 | const { stdout, stderr } = await execFileAsync( | ||
| 63 | process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass", | ||
| 64 | ["-p", "admin", "ssh", ...SSH_OPTS, `admin@${guestIp}`, wrapped], | ||
| 65 | { maxBuffer: 64 * 1024 * 1024 }, | ||
| 66 | ); | ||
| 67 | return { stdout, stderr, code: 0 }; | ||
| 68 | } catch (error) { | ||
| 69 | const err = error as { stdout?: string; stderr?: string; code?: number }; | ||
| 70 | return { | ||
| 71 | stdout: err.stdout ?? "", | ||
| 72 | stderr: err.stderr ?? "", | ||
| 73 | code: typeof err.code === "number" ? err.code : 1, | ||
| 74 | }; | ||
| 75 | } | ||
| 76 | }; | ||
| 77 | |||
| 78 | const fixturePath = (slug: string): string => | ||
| 79 | os === "linux" | ||
| 80 | ? `/home/admin/apps-sidecar-fixture-${slug}` | ||
| 81 | : `/Users/admin/apps-sidecar-fixture-${slug}`; | ||
| 82 | |||
| 83 | const writeFixtureCommand = (path: string): string => ` | ||
| 84 | set -eu | ||
| 85 | rm -rf '${path}' | ||
| 86 | mkdir -p '${path}/tools' | ||
| 87 | cat >'${path}/tools/greet.ts' <<'EOF' | ||
| 88 | ${"import"} { z } from "zod"; | ||
| 89 | ${"import"} { defineTool } from "executor:app"; | ||
| 90 | |||
| 91 | const Input = z.object({ name: z.string() }); | ||
| 92 | |||
| 93 | export default defineTool({ | ||
| 94 | description: "Greet", | ||
| 95 | input: Input, | ||
| 96 | async handler(input) { | ||
| 97 | return { greeting: "hello " + input.name }; | ||
| 98 | }, | ||
| 99 | }); | ||
| 100 | EOF | ||
| 101 | printf '%s' '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' >'${path}/package.json' | ||
| 102 | : >'${path}/bun.lock' | ||
| 103 | `; | ||
| 104 | |||
| 105 | const writeFixture = async (path: string): Promise<void> => { | ||
| 106 | const result = await ssh(writeFixtureCommand(path)); | ||
| 107 | expect( | ||
| 108 | result.code, | ||
| 109 | `fixture is written into the desktop guest\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, | ||
| 110 | ).toBe(0); | ||
| 111 | }; | ||
| 112 | |||
| 113 | const removeFixture = (path: string): Effect.Effect<void> => | ||
| 114 | Effect.promise(() => ssh(`rm -rf '${path}'`).then(() => undefined)).pipe(Effect.ignore); | ||
| 115 | |||
| 116 | const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; | ||
| 117 | |||
| 118 | const guestRequest = async <T>( | ||
| 119 | path: string, | ||
| 120 | init: { readonly method: "POST" | "DELETE"; readonly body?: unknown }, | ||
| 121 | timeoutSeconds = 30, | ||
| 122 | ): Promise<CurlResult<T>> => { | ||
| 123 | const body = init.body === undefined ? undefined : JSON.stringify(init.body); | ||
| 124 | const bodyFlag = body === undefined ? "" : ` --data-binary ${shellQuote(body)}`; | ||
| 125 | const command = [ | ||
| 126 | "curl", | ||
| 127 | "-sS", | ||
| 128 | "--max-time", | ||
| 129 | String(timeoutSeconds), | ||
| 130 | "-w", | ||
| 131 | shellQuote("\\n__HTTP_STATUS__:%{http_code}"), | ||
| 132 | "-X", | ||
| 133 | init.method, | ||
| 134 | "-H", | ||
| 135 | shellQuote(`Authorization: Bearer ${token()}`), | ||
| 136 | "-H", | ||
| 137 | shellQuote("content-type: application/json"), | ||
| 138 | bodyFlag, | ||
| 139 | shellQuote(`http://127.0.0.1:4789${path}`), | ||
| 140 | ].join(" "); | ||
| 141 | const result = await ssh(command); | ||
| 142 | expect( | ||
| 143 | result.code, | ||
| 144 | `${init.method} ${path} exits cleanly\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, | ||
| 145 | ).toBe(0); | ||
| 146 | const marker = "\n__HTTP_STATUS__:"; | ||
| 147 | const markerIndex = result.stdout.lastIndexOf(marker); | ||
| 148 | expect(markerIndex, `${init.method} ${path} printed an HTTP status`).toBeGreaterThanOrEqual(0); | ||
| 149 | const text = result.stdout.slice(0, markerIndex); | ||
| 150 | const status = Number(result.stdout.slice(markerIndex + marker.length).trim()); | ||
| 151 | return { status, body: (text.length > 0 ? JSON.parse(text) : null) as T, text }; | ||
| 152 | }; | ||
| 153 | |||
| 154 | const expectGuestRequest = async <T>( | ||
| 155 | path: string, | ||
| 156 | init: { readonly method: "POST" | "DELETE"; readonly body?: unknown }, | ||
| 157 | timeoutSeconds?: number, | ||
| 158 | ): Promise<T> => { | ||
| 159 | const result = await guestRequest<T>(path, init, timeoutSeconds); | ||
| 160 | expect(result.status, `${init.method} ${path}: ${result.text}`).toBe(200); | ||
| 161 | return result.body; | ||
| 162 | }; | ||
| 163 | |||
| 164 | const deleteSource = (slug: string): Effect.Effect<void> => | ||
| 165 | Effect.promise(() => | ||
| 166 | expectGuestRequest(`/api/apps/sources/${slug}`, { method: "DELETE" }).then(() => undefined), | ||
| 167 | ).pipe(Effect.ignore); | ||
| 168 | |||
| 169 | const syncAndInvoke = (input: { | ||
| 170 | readonly slug: string; | ||
| 171 | readonly path: string; | ||
| 172 | }): Effect.Effect<void> => | ||
| 173 | Effect.promise(async () => { | ||
| 174 | await expectGuestRequest("/api/apps/sources", { | ||
| 175 | method: "POST", | ||
| 176 | body: { | ||
| 177 | kind: "local-directory", | ||
| 178 | slug: input.slug, | ||
| 179 | app: input.slug, | ||
| 180 | path: input.path, | ||
| 181 | }, | ||
| 182 | }); | ||
| 183 | |||
| 184 | const sync = await expectGuestRequest<SyncResponse>( | ||
| 185 | `/api/apps/sources/${input.slug}/sync`, | ||
| 186 | { method: "POST" }, | ||
| 187 | 180, | ||
| 188 | ); | ||
| 189 | expect(sync.status, "the desktop sidecar bundled and published the tool").toBe("published"); | ||
| 190 | expect(sync.tools, "the desktop sidecar bundled and published the tool").toContain("greet"); | ||
| 191 | |||
| 192 | const execution = await expectGuestRequest<ExecuteResponse>("/api/executions", { | ||
| 193 | method: "POST", | ||
| 194 | body: { | ||
| 195 | code: `export default await tools["${input.slug}.org.published.greet"]({ name: "Ada" })`, | ||
| 196 | autoApprove: true, | ||
| 197 | }, | ||
| 198 | }); | ||
| 199 | expect( | ||
| 200 | JSON.stringify(execution.structured ?? execution.text), | ||
| 201 | "the published tool executes and returns the greeting", | ||
| 202 | ).toContain('"greeting":"hello Ada"'); | ||
| 203 | }); | ||
| 204 | |||
| 205 | if (!guestIp) { | ||
| 206 | it.skip(`${NAME} (needs a desktop guest with E2E_DESKTOP_VM_IP)`, () => {}); | ||
| 207 | } else if (os === "windows") { | ||
| 208 | it.skip(`${NAME} (desktop-windows does not boot a sidecar daemon with a known token; use cli-windows)`, () => {}); | ||
| 209 | } else { | ||
| 210 | scenario( | ||
| 211 | "Desktop sidecar · local-directory custom tool publishes and executes", | ||
| 212 | { timeout: 300_000 }, | ||
| 213 | Effect.gen(function* () { | ||
| 214 | const slug = `sidecar-smoke-${crypto.randomUUID().slice(0, 8)}`; | ||
| 215 | const path = fixturePath(slug); | ||
| 216 | |||
| 217 | yield* Effect.promise(() => writeFixture(path)).pipe( | ||
| 218 | Effect.andThen(syncAndInvoke({ slug, path })), | ||
| 219 | Effect.ensuring( | ||
| 220 | Effect.all([deleteSource(slug), removeFixture(path)], { | ||
| 221 | concurrency: "unbounded", | ||
| 222 | discard: true, | ||
| 223 | }), | ||
| 224 | ), | ||
| 225 | ); | ||
| 226 | }), | ||
| 227 | ); | ||
| 228 | } | ||
| @@ -0,0 +1,3 @@ | |||
| 1 | 48e7995f99423b64c8adf2aa07186571d9e1f66e | ||
| 2 | b8de904b34df6cad1dc29a49986f3703c0fe87f4 | ||
| 3 | 251a747551ac772f029d3f37e2e708e8fed20ee9 | ||
Binary file not shown.
Binary file not shown.
Binary file not shown.
| @@ -0,0 +1,115 @@ | |||
| 1 | // Local-only: custom tool local-directory sources can browse the user's own | ||
| 2 | // filesystem through the local app. The shared self-host target intentionally | ||
| 3 | // does not expose this source kind. | ||
| 4 | import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; | ||
| 5 | import { homedir } from "node:os"; | ||
| 6 | import { join } from "node:path"; | ||
| 7 | |||
| 8 | import { expect } from "@effect/vitest"; | ||
| 9 | import { Effect } from "effect"; | ||
| 10 | |||
| 11 | import { scenario } from "../src/scenario"; | ||
| 12 | import { Browser, Cli, RunDir, Target } from "../src/services"; | ||
| 13 | import { withLocalServer } from "./local-server"; | ||
| 14 | |||
| 15 | scenario( | ||
| 16 | "Local custom tools · the directory picker selects a local source path", | ||
| 17 | { timeout: 180_000 }, | ||
| 18 | Effect.gen(function* () { | ||
| 19 | const cli = yield* Cli; | ||
| 20 | const browser = yield* Browser; | ||
| 21 | const target = yield* Target; | ||
| 22 | const runDir = yield* RunDir; | ||
| 23 | const identity = yield* target.newIdentity(); | ||
| 24 | const fixtureRoot = mkdtempSync(join(homedir(), ".executor-picker-e2e-")); | ||
| 25 | const fixtureDir = join(fixtureRoot, "picked-tools"); | ||
| 26 | mkdirSync(join(fixtureDir, "tools"), { recursive: true }); | ||
| 27 | writeFileSync( | ||
| 28 | join(fixtureDir, "package.json"), | ||
| 29 | JSON.stringify({ | ||
| 30 | name: "picked-tools", | ||
| 31 | version: "0.0.0", | ||
| 32 | private: true, | ||
| 33 | dependencies: { zod: "^4.1.13" }, | ||
| 34 | }), | ||
| 35 | ); | ||
| 36 | writeFileSync( | ||
| 37 | join(fixtureDir, "tools", "echo.ts"), | ||
| 38 | ` | ||
| 39 | import { z } from "zod"; | ||
| 40 | import { defineTool } from "executor:app"; | ||
| 41 | |||
| 42 | export default defineTool({ | ||
| 43 | description: "Echo a picker message.", | ||
| 44 | input: z.object({ message: z.string() }), | ||
| 45 | handler(input) { | ||
| 46 | return { echoed: \`local-picker:\${input.message}\` }; | ||
| 47 | }, | ||
| 48 | }); | ||
| 49 | `, | ||
| 50 | ); | ||
| 51 | |||
| 52 | yield* withLocalServer(cli, runDir, ({ url }) => | ||
| 53 | browser.session(identity, async ({ page, step }) => { | ||
| 54 | await step("Open custom tools from the local console", async () => { | ||
| 55 | const addSourceUrl = new URL(url); | ||
| 56 | addSourceUrl.pathname = "/integrations/add/apps"; | ||
| 57 | await page.goto(addSourceUrl.toString(), { | ||
| 58 | waitUntil: "domcontentloaded", | ||
| 59 | }); | ||
| 60 | await page.getByRole("heading", { name: "Add custom tools" }).waitFor({ | ||
| 61 | timeout: 60_000, | ||
| 62 | }); | ||
| 63 | }); | ||
| 64 | |||
| 65 | await step("Browse to a local directory and select it", async () => { | ||
| 66 | await page.getByRole("button", { name: "Directory path" }).click(); | ||
| 67 | const pathInput = page.getByPlaceholder("/Users/me/tools"); | ||
| 68 | await pathInput.fill(fixtureRoot); | ||
| 69 | await page.getByRole("button", { name: "Browse" }).click(); | ||
| 70 | const dialog = page.getByRole("dialog", { name: "Choose directory" }); | ||
| 71 | await dialog | ||
| 72 | .getByRole("button", { name: /picked-tools/ }) | ||
| 73 | .getByText("tools", { exact: true }) | ||
| 74 | .waitFor({ timeout: 30_000 }); | ||
| 75 | await dialog.getByRole("button", { name: "picked-tools" }).click(); | ||
| 76 | await dialog.getByText("1 tool found:").waitFor({ timeout: 30_000 }); | ||
| 77 | await dialog.getByText("echo", { exact: true }).waitFor({ timeout: 30_000 }); | ||
| 78 | await dialog.getByRole("button", { name: "Select" }).click(); | ||
| 79 | expect(await pathInput.inputValue()).toBe(fixtureDir); | ||
| 80 | }); | ||
| 81 | |||
| 82 | await step("Sync the picked directory as a custom tools source", async () => { | ||
| 83 | await page.getByRole("button", { name: "Sync source" }).click(); | ||
| 84 | await page.waitForURL(/\/integrations\/picked-tools(?:\?|$)/, { timeout: 90_000 }); | ||
| 85 | await page.getByLabel("Source").getByText("1 tool").waitFor({ timeout: 90_000 }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | await step("Run the published local tool from the console", async () => { | ||
| 89 | await page.getByRole("tab", { name: "Tools" }).click(); | ||
| 90 | await page.getByRole("button", { name: /picked-tools\s+1/ }).click(); | ||
| 91 | await page.getByRole("button", { name: "echo", exact: true }).click(); | ||
| 92 | await page.getByRole("tab", { name: "Run" }).click(); | ||
| 93 | await page.getByLabel("message").fill("from directory picker"); | ||
| 94 | await page.getByRole("button", { name: "Run", exact: true }).click(); | ||
| 95 | await page | ||
| 96 | .locator("pre") | ||
| 97 | .filter({ hasText: "local-picker:from directory picker" }) | ||
| 98 | .last() | ||
| 99 | .waitFor({ timeout: 90_000 }); | ||
| 100 | }); | ||
| 101 | |||
| 102 | await step("Remove the local directory source", async () => { | ||
| 103 | await page.goto(new URL("/integrations/picked-tools?tab=source", url).toString(), { | ||
| 104 | waitUntil: "networkidle", | ||
| 105 | }); | ||
| 106 | await page.getByRole("button", { name: "Remove" }).click(); | ||
| 107 | await page.getByRole("button", { name: "Remove source" }).click(); | ||
| 108 | await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 }); | ||
| 109 | }); | ||
| 110 | }), | ||
| 111 | ).pipe( | ||
| 112 | Effect.ensuring(Effect.sync(() => rmSync(fixtureRoot, { recursive: true, force: true }))), | ||
| 113 | ); | ||
| 114 | }), | ||
| 115 | ); | ||
Binary file not shown.
| @@ -0,0 +1,520 @@ | |||
| 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 | const FIXTURE_GIT_TOKEN = "fixture-token"; | ||
| 16 | const EXPECTED_GIT_AUTHORIZATION = `Basic ${btoa(`git:${FIXTURE_GIT_TOKEN}`)}`; | ||
| 17 | |||
| 18 | const concat = (parts: readonly Uint8Array[]): Uint8Array => { | ||
| 19 | const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); | ||
| 20 | let offset = 0; | ||
| 21 | for (const part of parts) { | ||
| 22 | out.set(part, offset); | ||
| 23 | offset += part.length; | ||
| 24 | } | ||
| 25 | return out; | ||
| 26 | }; | ||
| 27 | |||
| 28 | const pktLine = (payload: string | Uint8Array): Uint8Array => { | ||
| 29 | const body = typeof payload === "string" ? textEncoder.encode(payload) : payload; | ||
| 30 | const header = textEncoder.encode((body.length + 4).toString(16).padStart(4, "0")); | ||
| 31 | return concat([header, body]); | ||
| 32 | }; | ||
| 33 | |||
| 34 | const FLUSH = textEncoder.encode("0000"); | ||
| 35 | |||
| 36 | const sideBand = (bytes: Uint8Array): Uint8Array => { | ||
| 37 | const chunks: Uint8Array[] = [pktLine("NAK\n")]; | ||
| 38 | for (let offset = 0; offset < bytes.length; offset += 60_000) { | ||
| 39 | const chunk = bytes.subarray(offset, offset + 60_000); | ||
| 40 | const payload = new Uint8Array(chunk.length + 1); | ||
| 41 | payload[0] = 1; | ||
| 42 | payload.set(chunk, 1); | ||
| 43 | chunks.push(pktLine(payload)); | ||
| 44 | } | ||
| 45 | chunks.push(FLUSH); | ||
| 46 | return concat(chunks); | ||
| 47 | }; | ||
| 48 | |||
| 49 | const advertisement = (sha: string): Uint8Array => | ||
| 50 | concat([ | ||
| 51 | pktLine("# service=git-upload-pack\n"), | ||
| 52 | FLUSH, | ||
| 53 | pktLine( | ||
| 54 | `${sha} HEAD\0symref=HEAD:refs/heads/main multi_ack side-band-64k thin-pack ofs-delta\n`, | ||
| 55 | ), | ||
| 56 | pktLine(`${sha} refs/heads/main\n`), | ||
| 57 | FLUSH, | ||
| 58 | ]); | ||
| 59 | |||
| 60 | const readFixture = async () => { | ||
| 61 | const dir = join(import.meta.dirname, "..", "fixtures", "custom-tools-git"); | ||
| 62 | const [shas, pack1, pack2, pack3] = await Promise.all([ | ||
| 63 | readFile(join(dir, "custom-tools-shas.txt"), "utf8"), | ||
| 64 | readFile(join(dir, "custom-tools-v1.pack")), | ||
| 65 | readFile(join(dir, "custom-tools-v2.pack")), | ||
| 66 | readFile(join(dir, "custom-tools-v3.pack")), | ||
| 67 | ]); | ||
| 68 | const [sha1, sha2, sha3] = shas.trim().split("\n"); | ||
| 69 | return { sha1: sha1!, sha2: sha2!, sha3: sha3!, pack1, pack2, pack3 }; | ||
| 70 | }; | ||
| 71 | |||
| 72 | const fixtureGitServer = Effect.acquireRelease( | ||
| 73 | Effect.promise(async () => { | ||
| 74 | const fixture = await readFixture(); | ||
| 75 | let current = { sha: fixture.sha1, pack: new Uint8Array(fixture.pack1) }; | ||
| 76 | let packRequests = 0; | ||
| 77 | const server = createServer((request: IncomingMessage, response: ServerResponse) => { | ||
| 78 | if ( | ||
| 79 | (request.url === "/repo.git/info/refs?service=git-upload-pack" || | ||
| 80 | request.url === "/repo.git/git-upload-pack") && | ||
| 81 | request.headers.authorization !== EXPECTED_GIT_AUTHORIZATION | ||
| 82 | ) { | ||
| 83 | response.writeHead(401, { | ||
| 84 | "content-type": "text/plain", | ||
| 85 | "www-authenticate": 'Basic realm="custom-tools-fixture"', | ||
| 86 | }); | ||
| 87 | response.end("authentication required"); | ||
| 88 | return; | ||
| 89 | } | ||
| 90 | if (request.url === "/repo.git/info/refs?service=git-upload-pack") { | ||
| 91 | response.writeHead(200, { | ||
| 92 | "content-type": "application/x-git-upload-pack-advertisement", | ||
| 93 | }); | ||
| 94 | response.end(advertisement(current.sha)); | ||
| 95 | return; | ||
| 96 | } | ||
| 97 | if (request.url === "/repo.git/git-upload-pack" && request.method === "POST") { | ||
| 98 | packRequests += 1; | ||
| 99 | response.writeHead(200, { "content-type": "application/x-git-upload-pack-result" }); | ||
| 100 | response.end(sideBand(current.pack)); | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | response.writeHead(404, { "content-type": "text/plain" }); | ||
| 104 | response.end("not found"); | ||
| 105 | }); | ||
| 106 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); | ||
| 107 | const address = server.address(); | ||
| 108 | if (!address || typeof address === "string") throw new Error("fixture server did not bind"); | ||
| 109 | return { | ||
| 110 | url: `http://127.0.0.1:${address.port}/repo.git`, | ||
| 111 | advance: () => { | ||
| 112 | current = { sha: fixture.sha2, pack: new Uint8Array(fixture.pack2) }; | ||
| 113 | }, | ||
| 114 | failCollect: () => { | ||
| 115 | current = { sha: fixture.sha3, pack: new Uint8Array(fixture.pack3) }; | ||
| 116 | }, | ||
| 117 | restoreGood: () => { | ||
| 118 | current = { sha: fixture.sha2, pack: new Uint8Array(fixture.pack2) }; | ||
| 119 | }, | ||
| 120 | packRequests: () => packRequests, | ||
| 121 | close: () => new Promise<void>((resolve) => server.close(() => resolve())), | ||
| 122 | }; | ||
| 123 | }), | ||
| 124 | (server) => Effect.promise(() => server.close()).pipe(Effect.ignore), | ||
| 125 | ); | ||
| 126 | |||
| 127 | interface ToolRow { | ||
| 128 | readonly address: string; | ||
| 129 | readonly name: string; | ||
| 130 | readonly integration: string; | ||
| 131 | } | ||
| 132 | |||
| 133 | interface ExecuteResponse { | ||
| 134 | readonly status: "completed" | "paused"; | ||
| 135 | readonly text: string; | ||
| 136 | readonly structured: unknown; | ||
| 137 | readonly isError?: boolean; | ||
| 138 | } | ||
| 139 | |||
| 140 | const authHeaders = async ( | ||
| 141 | target: TargetShape, | ||
| 142 | identity: Identity, | ||
| 143 | ): Promise<Record<string, string>> => { | ||
| 144 | if (identity.headers) return identity.headers; | ||
| 145 | const credentials = identity.credentials; | ||
| 146 | if (!credentials) throw new Error(`identity ${identity.label} has no credentials`); | ||
| 147 | const response = await fetch(new URL("/api/auth/sign-in/email", target.baseUrl), { | ||
| 148 | method: "POST", | ||
| 149 | headers: { "content-type": "application/json", origin: new URL(target.baseUrl).origin }, | ||
| 150 | body: JSON.stringify(credentials), | ||
| 151 | redirect: "manual", | ||
| 152 | }); | ||
| 153 | const cookie = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; "); | ||
| 154 | if (!cookie) throw new Error(`sign-in set no cookie (${response.status})`); | ||
| 155 | return { cookie }; | ||
| 156 | }; | ||
| 157 | |||
| 158 | const request = async <T>( | ||
| 159 | target: TargetShape, | ||
| 160 | identity: Identity, | ||
| 161 | path: string, | ||
| 162 | init: RequestInit = {}, | ||
| 163 | expectedStatus = 200, | ||
| 164 | ): Promise<{ readonly body: T; readonly text: string }> => { | ||
| 165 | const headers = new Headers(init.headers); | ||
| 166 | headers.set("origin", new URL(target.baseUrl).origin); | ||
| 167 | for (const [name, value] of Object.entries(await authHeaders(target, identity))) { | ||
| 168 | headers.set(name, value); | ||
| 169 | } | ||
| 170 | if (init.body !== undefined && !headers.has("content-type")) { | ||
| 171 | headers.set("content-type", "application/json"); | ||
| 172 | } | ||
| 173 | const response = await fetch(new URL(path, target.baseUrl), { ...init, headers }); | ||
| 174 | const text = await response.text(); | ||
| 175 | expect(response.status, `${init.method ?? "GET"} ${path}: ${text}`).toBe(expectedStatus); | ||
| 176 | return { body: (text.length > 0 ? JSON.parse(text) : null) as T, text }; | ||
| 177 | }; | ||
| 178 | |||
| 179 | const postJson = <T>( | ||
| 180 | target: TargetShape, | ||
| 181 | identity: Identity, | ||
| 182 | path: string, | ||
| 183 | body?: unknown, | ||
| 184 | expectedStatus = 200, | ||
| 185 | ): Promise<{ readonly body: T; readonly text: string }> => | ||
| 186 | request<T>( | ||
| 187 | target, | ||
| 188 | identity, | ||
| 189 | path, | ||
| 190 | { | ||
| 191 | method: "POST", | ||
| 192 | ...(body !== undefined ? { body: JSON.stringify(body) } : {}), | ||
| 193 | }, | ||
| 194 | expectedStatus, | ||
| 195 | ); | ||
| 196 | |||
| 197 | const deletePath = ( | ||
| 198 | target: TargetShape, | ||
| 199 | identity: Identity, | ||
| 200 | path: string, | ||
| 201 | ): Promise<{ readonly body: { readonly removed?: boolean }; readonly text: string }> => | ||
| 202 | request(target, identity, path, { method: "DELETE" }); | ||
| 203 | |||
| 204 | const execute = (target: TargetShape, identity: Identity, code: string): Promise<ExecuteResponse> => | ||
| 205 | postJson<ExecuteResponse>(target, identity, "/api/executions", { | ||
| 206 | code, | ||
| 207 | autoApprove: true, | ||
| 208 | }).then((response) => response.body); | ||
| 209 | |||
| 210 | const addSourceThroughConsole = (input: { | ||
| 211 | readonly target: TargetShape; | ||
| 212 | readonly browser: BrowserSurface; | ||
| 213 | readonly identity: Identity; | ||
| 214 | readonly sourceUrl: string; | ||
| 215 | }) => | ||
| 216 | input.browser.session(input.identity, async ({ page, step }) => { | ||
| 217 | await step("Open the integrations page", async () => { | ||
| 218 | await page.goto(new URL("/integrations", input.target.baseUrl).toString(), { | ||
| 219 | waitUntil: "networkidle", | ||
| 220 | }); | ||
| 221 | }); | ||
| 222 | |||
| 223 | await step("Detect the Git repository as custom tools", async () => { | ||
| 224 | await page.getByRole("button", { name: "Connect", exact: true }).click(); | ||
| 225 | const dialog = page.getByRole("dialog"); | ||
| 226 | await dialog.getByRole("textbox").fill(input.sourceUrl); | ||
| 227 | await dialog.getByRole("button", { name: "Detect" }).click(); | ||
| 228 | await page.getByRole("heading", { name: "Add custom tools" }).waitFor(); | ||
| 229 | }); | ||
| 230 | |||
| 231 | await step("Sync the custom tools source", async () => { | ||
| 232 | await page.locator('input[type="password"]').fill(FIXTURE_GIT_TOKEN); | ||
| 233 | await page.getByRole("button", { name: "Sync source" }).click(); | ||
| 234 | await page.waitForURL(/\/integrations\/repo(?:\?|$)/, { timeout: 90_000 }); | ||
| 235 | await page.getByLabel("Source").getByText("2 tools").waitFor({ timeout: 90_000 }); | ||
| 236 | await page.getByRole("link", { name: "repo" }).waitFor({ timeout: 90_000 }); | ||
| 237 | await page.getByRole("tab", { name: "Tools" }).click(); | ||
| 238 | await page.getByRole("button", { name: /repo\s+2/ }).click(); | ||
| 239 | await page.getByRole("button", { name: "echo-tool", exact: true }).click(); | ||
| 240 | await page.getByRole("tab", { name: "Run" }).click(); | ||
| 241 | expect(await page.getByLabel("Connection").count()).toBe(0); | ||
| 242 | await page.getByLabel("message").fill("from run tab"); | ||
| 243 | await page.getByLabel("apps").selectOption("0"); | ||
| 244 | await page.getByRole("button", { name: "Run", exact: true }).click(); | ||
| 245 | await page | ||
| 246 | .locator("pre") | ||
| 247 | .filter({ hasText: "from run tab" }) | ||
| 248 | .filter({ hasText: "v1" }) | ||
| 249 | .last() | ||
| 250 | .waitFor({ timeout: 90_000 }); | ||
| 251 | }); | ||
| 252 | }); | ||
| 253 | |||
| 254 | const assertBadTokenFailureThroughConsole = (input: { | ||
| 255 | readonly target: TargetShape; | ||
| 256 | readonly browser: BrowserSurface; | ||
| 257 | readonly identity: Identity; | ||
| 258 | readonly sourceUrl: string; | ||
| 259 | }) => | ||
| 260 | input.browser.session(input.identity, async ({ page, step }) => { | ||
| 261 | await step("Try to sync the custom tools source with a bad token", async () => { | ||
| 262 | await page.goto(new URL("/integrations", input.target.baseUrl).toString(), { | ||
| 263 | waitUntil: "networkidle", | ||
| 264 | }); | ||
| 265 | await page.getByRole("button", { name: "Connect", exact: true }).click(); | ||
| 266 | const dialog = page.getByRole("dialog"); | ||
| 267 | await dialog.getByRole("textbox").fill(input.sourceUrl); | ||
| 268 | await dialog.getByRole("button", { name: "Detect" }).click(); | ||
| 269 | await page.getByRole("heading", { name: "Add custom tools" }).waitFor(); | ||
| 270 | await page.locator('input[type="password"]').fill("wrong-token"); | ||
| 271 | const syncResponse = page.waitForResponse( | ||
| 272 | (response) => | ||
| 273 | response.url().includes("/api/apps/sources/repo/sync") && response.status() === 200, | ||
| 274 | { timeout: 90_000 }, | ||
| 275 | ); | ||
| 276 | await page.getByRole("button", { name: "Sync source" }).click(); | ||
| 277 | await syncResponse; | ||
| 278 | }); | ||
| 279 | |||
| 280 | await step("See the failed sync on the source panel", async () => { | ||
| 281 | await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), { | ||
| 282 | waitUntil: "networkidle", | ||
| 283 | }); | ||
| 284 | await page.getByText("Last sync failed").waitFor({ timeout: 90_000 }); | ||
| 285 | await page.getByText("source: info/refs 401").waitFor({ timeout: 90_000 }); | ||
| 286 | }); | ||
| 287 | |||
| 288 | await step("Remove the failed source before retrying with the good token", async () => { | ||
| 289 | await page.getByRole("button", { name: "Remove" }).click(); | ||
| 290 | await page.getByRole("button", { name: "Remove source" }).click(); | ||
| 291 | await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 }); | ||
| 292 | }); | ||
| 293 | }); | ||
| 294 | |||
| 295 | const syncSourceInConsole = (input: { | ||
| 296 | readonly target: TargetShape; | ||
| 297 | readonly browser: BrowserSurface; | ||
| 298 | readonly identity: Identity; | ||
| 299 | readonly expectedNotice: string; | ||
| 300 | readonly expectedToolCount: string; | ||
| 301 | }) => | ||
| 302 | input.browser.session(input.identity, async ({ page, step }) => { | ||
| 303 | await step(`Sync custom tools and see ${input.expectedNotice}`, async () => { | ||
| 304 | await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), { | ||
| 305 | waitUntil: "networkidle", | ||
| 306 | }); | ||
| 307 | await page.getByRole("button", { name: "Sync" }).click(); | ||
| 308 | await page.getByText(input.expectedNotice).waitFor({ timeout: 90_000 }); | ||
| 309 | await page | ||
| 310 | .locator("p") | ||
| 311 | .filter({ hasText: new RegExp(`^${input.expectedToolCount}$`) }) | ||
| 312 | .waitFor({ timeout: 90_000 }); | ||
| 313 | }); | ||
| 314 | }); | ||
| 315 | |||
| 316 | const syncCollectFailureInConsole = (input: { | ||
| 317 | readonly target: TargetShape; | ||
| 318 | readonly browser: BrowserSurface; | ||
| 319 | readonly identity: Identity; | ||
| 320 | }) => | ||
| 321 | input.browser.session(input.identity, async ({ page, step }) => { | ||
| 322 | await step("Sync custom tools and see the collect diagnostic", async () => { | ||
| 323 | await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), { | ||
| 324 | waitUntil: "networkidle", | ||
| 325 | }); | ||
| 326 | await page.getByRole("button", { name: "Sync" }).click(); | ||
| 327 | await page.getByText("Sync failed.").waitFor({ timeout: 90_000 }); | ||
| 328 | await page | ||
| 329 | .locator("p") | ||
| 330 | .filter({ hasText: "collect: record export key" }) | ||
| 331 | .first() | ||
| 332 | .waitFor({ timeout: 90_000 }); | ||
| 333 | await page | ||
| 334 | .locator("p") | ||
| 335 | .filter({ hasText: /^0 tools$/ }) | ||
| 336 | .waitFor({ timeout: 90_000 }); | ||
| 337 | }); | ||
| 338 | }); | ||
| 339 | |||
| 340 | const removeSourceThroughConsole = (input: { | ||
| 341 | readonly target: TargetShape; | ||
| 342 | readonly browser: BrowserSurface; | ||
| 343 | readonly identity: Identity; | ||
| 344 | }) => | ||
| 345 | input.browser.session(input.identity, async ({ page, step }) => { | ||
| 346 | await step("Remove the custom tools source", async () => { | ||
| 347 | await page.goto(new URL("/integrations/repo?tab=source", input.target.baseUrl).toString(), { | ||
| 348 | waitUntil: "networkidle", | ||
| 349 | }); | ||
| 350 | await page.getByRole("button", { name: "Remove" }).click(); | ||
| 351 | await page.getByRole("button", { name: "Remove source" }).click(); | ||
| 352 | await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 }); | ||
| 353 | }); | ||
| 354 | }); | ||
| 355 | |||
| 356 | scenario( | ||
| 357 | "Custom tools · Git source syncs, invokes, refreshes, and removes", | ||
| 358 | { timeout: 300_000 }, | ||
| 359 | Effect.scoped( | ||
| 360 | Effect.gen(function* () { | ||
| 361 | const target = yield* Target; | ||
| 362 | const browser = yield* Browser; | ||
| 363 | yield* Api; | ||
| 364 | const identity = yield* target.newIdentity(); | ||
| 365 | const git = yield* fixtureGitServer; | ||
| 366 | |||
| 367 | yield* Effect.ensuring( | ||
| 368 | Effect.gen(function* () { | ||
| 369 | const unauthorized = yield* Effect.promise(() => | ||
| 370 | fetch(new URL("/api/apps/sources/custom-tools/sync", target.baseUrl), { | ||
| 371 | method: "POST", | ||
| 372 | }), | ||
| 373 | ); | ||
| 374 | expect(unauthorized.status, "sync requires authentication").toBe( | ||
| 375 | target.name === "cloud" ? 403 : 401, | ||
| 376 | ); | ||
| 377 | yield* Effect.promise(() => unauthorized.text()); | ||
| 378 | |||
| 379 | const missingGitAuth = yield* Effect.promise(() => | ||
| 380 | fetch(`${git.url}/info/refs?service=git-upload-pack`), | ||
| 381 | ); | ||
| 382 | expect(missingGitAuth.status, "fixture git server requires auth").toBe(401); | ||
| 383 | yield* Effect.promise(() => missingGitAuth.text()); | ||
| 384 | |||
| 385 | yield* assertBadTokenFailureThroughConsole({ | ||
| 386 | target, | ||
| 387 | browser, | ||
| 388 | identity, | ||
| 389 | sourceUrl: git.url, | ||
| 390 | }); | ||
| 391 | expect(git.packRequests(), "bad token fails before fetching a pack").toBe(0); | ||
| 392 | |||
| 393 | yield* addSourceThroughConsole({ target, browser, identity, sourceUrl: git.url }); | ||
| 394 | |||
| 395 | const sources = yield* Effect.promise(() => | ||
| 396 | request<{ | ||
| 397 | readonly sources: readonly { | ||
| 398 | readonly slug: string; | ||
| 399 | readonly app: string; | ||
| 400 | readonly config: { readonly kind: string; readonly url?: string }; | ||
| 401 | }[]; | ||
| 402 | }>(target, identity, "/api/apps/sources"), | ||
| 403 | ); | ||
| 404 | expect(sources.text).not.toContain("fixture-token"); | ||
| 405 | const source = sources.body.sources.find((item) => item.app === "repo"); | ||
| 406 | expect(source).toMatchObject({ | ||
| 407 | app: "repo", | ||
| 408 | config: { kind: "git", url: git.url }, | ||
| 409 | }); | ||
| 410 | |||
| 411 | const tools = yield* Effect.promise(() => | ||
| 412 | request<readonly ToolRow[]>(target, identity, "/api/tools?integration=repo"), | ||
| 413 | ); | ||
| 414 | expect(tools.body.map((tool) => tool.name).sort()).toEqual(["echo-tool", "static-tool"]); | ||
| 415 | const echoTool = tools.body.find((tool) => tool.name === "echo-tool"); | ||
| 416 | expect(echoTool).toBeDefined(); | ||
| 417 | |||
| 418 | const schema = yield* Effect.promise(() => | ||
| 419 | request<{ | ||
| 420 | readonly inputSchema: { | ||
| 421 | readonly properties?: Record< | ||
| 422 | string, | ||
| 423 | { | ||
| 424 | readonly type?: string; | ||
| 425 | readonly enum?: readonly string[]; | ||
| 426 | readonly default?: unknown; | ||
| 427 | } | ||
| 428 | >; | ||
| 429 | readonly required?: readonly string[]; | ||
| 430 | }; | ||
| 431 | }>( | ||
| 432 | target, | ||
| 433 | identity, | ||
| 434 | `/api/tools/schema?address=${encodeURIComponent(echoTool!.address)}`, | ||
| 435 | ), | ||
| 436 | ); | ||
| 437 | expect(schema.body.inputSchema.properties?.apps).toMatchObject({ | ||
| 438 | enum: ["tools.repo.org.published"], | ||
| 439 | default: "tools.repo.org.published", | ||
| 440 | }); | ||
| 441 | expect(schema.body.inputSchema.properties?.message).toMatchObject({ type: "string" }); | ||
| 442 | expect(schema.body.inputSchema.required ?? []).toContain("message"); | ||
| 443 | expect(schema.body.inputSchema.required ?? []).not.toContain("apps"); | ||
| 444 | |||
| 445 | const invoked = yield* Effect.promise(() => | ||
| 446 | execute( | ||
| 447 | target, | ||
| 448 | identity, | ||
| 449 | `return await tools["repo.org.published.echo-tool"]({ message: "hello", apps: "tools.repo.org.published" });`, | ||
| 450 | ), | ||
| 451 | ); | ||
| 452 | expect(invoked.status, invoked.text).toBe("completed"); | ||
| 453 | expect(invoked.isError, invoked.text).toBe(false); | ||
| 454 | expect(JSON.stringify(invoked.structured)).toContain("hello"); | ||
| 455 | expect(JSON.stringify(invoked.structured)).toContain("v1"); | ||
| 456 | |||
| 457 | expect(git.packRequests(), "initial publish fetched one pack").toBe(1); | ||
| 458 | git.advance(); | ||
| 459 | yield* syncSourceInConsole({ | ||
| 460 | target, | ||
| 461 | browser, | ||
| 462 | identity, | ||
| 463 | expectedNotice: "Added: extra-tool", | ||
| 464 | expectedToolCount: "3 tools", | ||
| 465 | }); | ||
| 466 | |||
| 467 | yield* syncSourceInConsole({ | ||
| 468 | target, | ||
| 469 | browser, | ||
| 470 | identity, | ||
| 471 | expectedNotice: "Already up to date.", | ||
| 472 | expectedToolCount: "3 tools", | ||
| 473 | }); | ||
| 474 | |||
| 475 | git.failCollect(); | ||
| 476 | yield* syncCollectFailureInConsole({ target, browser, identity }); | ||
| 477 | |||
| 478 | const afterFailedCollect = yield* Effect.promise(() => | ||
| 479 | request<readonly ToolRow[]>(target, identity, "/api/tools?integration=repo"), | ||
| 480 | ); | ||
| 481 | expect(afterFailedCollect.body.map((tool) => tool.name).sort()).toEqual([ | ||
| 482 | "echo-tool", | ||
| 483 | "extra-tool", | ||
| 484 | "static-tool", | ||
| 485 | ]); | ||
| 486 | |||
| 487 | git.restoreGood(); | ||
| 488 | yield* syncSourceInConsole({ | ||
| 489 | target, | ||
| 490 | browser, | ||
| 491 | identity, | ||
| 492 | expectedNotice: "Already up to date.", | ||
| 493 | expectedToolCount: "3 tools", | ||
| 494 | }); | ||
| 495 | |||
| 496 | yield* removeSourceThroughConsole({ target, browser, identity }); | ||
| 497 | |||
| 498 | const afterRemove = yield* Effect.promise(() => | ||
| 499 | request<readonly ToolRow[]>(target, identity, "/api/tools?integration=repo"), | ||
| 500 | ); | ||
| 501 | expect(afterRemove.body).toEqual([]); | ||
| 502 | }), | ||
| 503 | Effect.promise(async () => { | ||
| 504 | const sources = await request<{ | ||
| 505 | readonly sources: readonly { readonly slug: string; readonly app: string }[]; | ||
| 506 | }>(target, identity, "/api/apps/sources").catch(() => ({ body: { sources: [] } })); | ||
| 507 | for (const source of sources.body.sources) { | ||
| 508 | if (source.app === "repo") { | ||
| 509 | await deletePath( | ||
| 510 | target, | ||
| 511 | identity, | ||
| 512 | `/api/apps/sources/${encodeURIComponent(source.slug)}`, | ||
| 513 | ).catch(() => undefined); | ||
| 514 | } | ||
| 515 | } | ||
| 516 | }).pipe(Effect.ignore), | ||
| 517 | ); | ||
| 518 | }), | ||
| 519 | ), | ||
| 520 | ); | ||
| @@ -87,6 +87,7 @@ const provisionLinux = async (): Promise<ProvisionedGuest> => { · 86 unmodified lines | |||
| 87 | await vm.ssh( | 87 | await vm.ssh( |
| 88 | `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-linux-e2e EXECUTOR_CLIENT=desktop ` + | 88 | `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-linux-e2e EXECUTOR_CLIENT=desktop ` + |
| 89 | `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` + | 89 | `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` + |
| 90 | `--auth-token desktop-linux-e2e ` + | ||
| 90 | `>/tmp/executor-daemon.log 2>&1 &`, | 91 | `>/tmp/executor-daemon.log 2>&1 &`, |
| 91 | ); | 92 | ); |
| 92 | if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) { | 93 | if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) { |
| @@ -78,6 +78,7 @@ const provisionMac = async (): Promise<ProvisionedGuest> => { · 77 unmodified lines | |||
| 78 | await vm.ssh( | 78 | await vm.ssh( |
| 79 | `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-macos-e2e EXECUTOR_CLIENT=desktop ` + | 79 | `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-macos-e2e EXECUTOR_CLIENT=desktop ` + |
| 80 | `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` + | 80 | `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` + |
| 81 | `--auth-token desktop-macos-e2e ` + | ||
| 81 | `>/tmp/executor-daemon.log 2>&1 &`, | 82 | `>/tmp/executor-daemon.log 2>&1 &`, |
| 82 | ); | 83 | ); |
| 83 | if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) { | 84 | if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) { |
| @@ -362,13 +362,14 @@ describe("connections.refresh", () => { · 361 unmodified lines | |||
| 362 | 362 | ||
| 363 | describe("tool catalog sync safety", () => { | 363 | describe("tool catalog sync safety", () => { |
| 364 | it.effect( | 364 | it.effect( |
| 365 | "background sync preserves a nonzero catalog when a plugin returns authoritative empty", | 365 | "background sync preserves a nonzero remote catalog when a plugin returns authoritative empty", |
| 366 | () => | 366 | () => |
| 367 | Effect.scoped( | 367 | Effect.scoped( |
| 368 | Effect.gen(function* () { | 368 | Effect.gen(function* () { |
| 369 | let empty = false; | 369 | let empty = false; |
| 370 | const guardedPlugin = definePlugin(() => ({ | 370 | const guardedPlugin = definePlugin(() => ({ |
| 371 | id: "guarded" as const, | 371 | id: "guarded" as const, |
| 372 | remoteToolCatalog: true, | ||
| 372 | credentialProviders: [memoryProvider()], | 373 | credentialProviders: [memoryProvider()], |
| 373 | storage: () => ({}), | 374 | storage: () => ({}), |
| 374 | resolveTools: () => | 375 | resolveTools: () => |
| @@ -424,6 +425,66 @@ describe("tool catalog sync safety", () => { · 49 unmodified lines | |||
| 424 | ), | 425 | ), |
| 425 | ); | 426 | ); |
| 426 | 427 | ||
| 428 | it.effect( | ||
| 429 | "background sync clears a non-remote catalog when a plugin returns authoritative empty", | ||
| 430 | () => | ||
| 431 | Effect.scoped( | ||
| 432 | Effect.gen(function* () { | ||
| 433 | let empty = false; | ||
| 434 | const storedStatePlugin = definePlugin(() => ({ | ||
| 435 | id: "stored-state" as const, | ||
| 436 | credentialProviders: [memoryProvider()], | ||
| 437 | storage: () => ({}), | ||
| 438 | resolveTools: () => | ||
| 439 | Effect.sync(() => ({ | ||
| 440 | tools: empty | ||
| 441 | ? [] | ||
| 442 | : [ | ||
| 443 | { name: ToolName.make("deploy"), description: "deploy" }, | ||
| 444 | { name: ToolName.make("list"), description: "list" }, | ||
| 445 | ], | ||
| 446 | })), | ||
| 447 | invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), | ||
| 448 | extension: (ctx) => ({ | ||
| 449 | seed: () => | ||
| 450 | ctx.core.integrations.register({ | ||
| 451 | slug: INTEG, | ||
| 452 | description: "Vercel", | ||
| 453 | config: {}, | ||
| 454 | }), | ||
| 455 | }), | ||
| 456 | }))(); | ||
| 457 | const config = makeTestConfig({ plugins: [storedStatePlugin] as const }); | ||
| 458 | const executor = yield* createExecutor(config); | ||
| 459 | yield* executor["stored-state"].seed(); | ||
| 460 | yield* executor.connections.create({ | ||
| 461 | owner: "org", | ||
| 462 | name: ConnectionName.make("main"), | ||
| 463 | integration: INTEG, | ||
| 464 | template: TEMPLATE, | ||
| 465 | value: "secret-token", | ||
| 466 | }); | ||
| 467 | |||
| 468 | empty = true; | ||
| 469 | yield* Effect.promise(() => | ||
| 470 | config.db.updateMany("connection", { | ||
| 471 | where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")), | ||
| 472 | set: { tools_synced_at: null }, | ||
| 473 | }), | ||
| 474 | ); | ||
| 475 | const tools = yield* executor.tools.list({ integration: INTEG }); | ||
| 476 | const connection = yield* executor.connections.get({ | ||
| 477 | owner: "org", | ||
| 478 | integration: INTEG, | ||
| 479 | name: ConnectionName.make("main"), | ||
| 480 | }); | ||
| 481 | |||
| 482 | expect(tools).toEqual([]); | ||
| 483 | expect(connection?.lastHealth).toBeNull(); | ||
| 484 | }), | ||
| 485 | ), | ||
| 486 | ); | ||
| 487 | |||
| 427 | it.effect("explicit refresh accepts an authoritative empty catalog", () => | 488 | it.effect("explicit refresh accepts an authoritative empty catalog", () => |
| 428 | Effect.scoped( | 489 | Effect.scoped( |
| 429 | Effect.gen(function* () { | 490 | Effect.gen(function* () { |
| @@ -2167,7 +2167,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2166 unmodified lines | |||
| 2167 | return keptRows.map((row) => rowToTool(row as ConnectionToolRow)); | 2167 | return keptRows.map((row) => rowToTool(row as ConnectionToolRow)); |
| 2168 | } | 2168 | } |
| 2169 | 2169 | ||
| 2170 | if (mode === "background" && result.tools.length === 0) { | 2170 | if ( |
| 2171 | mode === "background" && | ||
| 2172 | runtime.plugin.remoteToolCatalog === true && | ||
| 2173 | result.tools.length === 0 | ||
| 2174 | ) { | ||
| 2171 | const keptRows = yield* core.findMany("tool", { where }); | 2175 | const keptRows = yield* core.findMany("tool", { where }); |
| 2172 | if (keptRows.length > 0) { | 2176 | if (keptRows.length > 0) { |
| 2173 | const reason = | 2177 | const reason = |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { stat } from "node:fs/promises"; | 2 | import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; |
| 3 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | 4 | import { join } from "node:path"; |
| 4 | import * as Data from "effect/Data"; | 5 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| @@ -9,6 +10,7 @@ import { · 3 unmodified lines | |||
| 9 | createWorkerdModuleRunner, | 10 | createWorkerdModuleRunner, |
| 10 | isWorkerdAvailable, | 11 | isWorkerdAvailable, |
| 11 | makeWorkerdSubprocessExecutor, | 12 | makeWorkerdSubprocessExecutor, |
| 13 | workerdBinaryCandidatesForTest, | ||
| 12 | WORKERD_VERSION, | 14 | WORKERD_VERSION, |
| 13 | } from "./index"; | 15 | } from "./index"; |
| 14 | 16 | ||
| @@ -107,6 +109,44 @@ it("exports the pinned workerd version", () => { · 92 unmodified lines | |||
| 107 | expect(WORKERD_VERSION).toBe("1.20260708.1"); | 109 | expect(WORKERD_VERSION).toBe("1.20260708.1"); |
| 108 | }); | 110 | }); |
| 109 | 111 | ||
| 112 | describe("workerd binary resolution", () => { | ||
| 113 | it("honors EXECUTOR_WORKERD_BIN before probing", async () => { | ||
| 114 | const tmp = await mkdtemp(join(tmpdir(), "executor-workerd-bin-test-")); | ||
| 115 | const previous = process.env.EXECUTOR_WORKERD_BIN; | ||
| 116 | const bin = join(tmp, "workerd"); | ||
| 117 | try { | ||
| 118 | await writeFile(bin, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); | ||
| 119 | await chmod(bin, 0o755); | ||
| 120 | process.env.EXECUTOR_WORKERD_BIN = bin; | ||
| 121 | expect(isWorkerdAvailable()).toBe(true); | ||
| 122 | } finally { | ||
| 123 | if (previous === undefined) { | ||
| 124 | delete process.env.EXECUTOR_WORKERD_BIN; | ||
| 125 | } else { | ||
| 126 | process.env.EXECUTOR_WORKERD_BIN = previous; | ||
| 127 | } | ||
| 128 | await rm(tmp, { recursive: true, force: true }); | ||
| 129 | } | ||
| 130 | }); | ||
| 131 | |||
| 132 | it("orders generic package probes before platform package probes", () => { | ||
| 133 | const roots = ["/app", "/workspace"]; | ||
| 134 | expect(workerdBinaryCandidatesForTest(roots, "darwin", "arm64")).toEqual([ | ||
| 135 | "/app/node_modules/workerd/bin/workerd", | ||
| 136 | "/workspace/node_modules/workerd/bin/workerd", | ||
| 137 | "/app/node_modules/@cloudflare/workerd-darwin-arm64/bin/workerd", | ||
| 138 | "/app/node_modules/@cloudflare/workerd-darwin-arm64/workerd", | ||
| 139 | "/workspace/node_modules/@cloudflare/workerd-darwin-arm64/bin/workerd", | ||
| 140 | "/workspace/node_modules/@cloudflare/workerd-darwin-arm64/workerd", | ||
| 141 | ]); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("probes workerd.exe in the Windows package root layout", () => { | ||
| 145 | const candidates = workerdBinaryCandidatesForTest(["C:\\app"], "win32", "x64"); | ||
| 146 | expect(candidates).toContain("C:\\app/node_modules/@cloudflare/workerd-windows-64/workerd.exe"); | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 110 | describe.skipIf(!isWorkerdAvailable())("runtime-workerd-subprocess", () => { | 150 | describe.skipIf(!isWorkerdAvailable())("runtime-workerd-subprocess", () => { |
| 111 | it("boots and executes a trivial module", async () => { | 151 | it("boots and executes a trivial module", async () => { |
| 112 | const runner = makeRunner(); | 152 | const runner = makeRunner(); |
| @@ -1,11 +1,14 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-promise-reject, executor/no-instanceof-tagged-error -- subprocess boundary normalizes JavaScript errors into typed runtime errors */ | 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-promise-reject, executor/no-instanceof-tagged-error -- subprocess boundary normalizes JavaScript errors into typed runtime errors */ |
| 2 | import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process"; | 2 | import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process"; |
| 3 | import { existsSync, readFileSync } from "node:fs"; | ||
| 3 | import { access, mkdir, rm, writeFile } from "node:fs/promises"; | 4 | import { access, mkdir, rm, writeFile } from "node:fs/promises"; |
| 4 | import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; | 5 | import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; |
| 6 | import { createRequire } from "node:module"; | ||
| 5 | import { createServer as createNetServer, type Server as NetServer } from "node:net"; | 7 | import { createServer as createNetServer, type Server as NetServer } from "node:net"; |
| 6 | import type { Readable } from "node:stream"; | 8 | import type { Readable } from "node:stream"; |
| 7 | import { tmpdir } from "node:os"; | 9 | import { tmpdir } from "node:os"; |
| 8 | import { dirname, join } from "node:path"; | 10 | import { dirname, join } from "node:path"; |
| 11 | import { rootCertificates } from "node:tls"; | ||
| 9 | import { fileURLToPath } from "node:url"; | 12 | import { fileURLToPath } from "node:url"; |
| 10 | 13 | ||
| 11 | import { | 14 | import { |
| @@ -77,6 +80,7 @@ type PendingRequest = { · 65 unmodified lines | |||
| 77 | }; | 80 | }; |
| 78 | 81 | ||
| 79 | const packageDir = dirname(dirname(fileURLToPath(import.meta.url))); | 82 | const packageDir = dirname(dirname(fileURLToPath(import.meta.url))); |
| 83 | const packageRequire = createRequire(import.meta.url); | ||
| 80 | 84 | ||
| 81 | const normalizeError = (cause: unknown): Error => | 85 | const normalizeError = (cause: unknown): Error => |
| 82 | cause instanceof Error ? cause : new Error(String(cause)); | 86 | cause instanceof Error ? cause : new Error(String(cause)); |
| @@ -150,16 +154,86 @@ const reserveLocalPort = (): Promise<{ · 67 unmodified lines | |||
| 150 | }); | 154 | }); |
| 151 | }); | 155 | }); |
| 152 | 156 | ||
| 153 | const findWorkerdBin = async (configured?: string): Promise<string> => { | 157 | const workerdBinaryName = (platform: NodeJS.Platform = process.platform): string => |
| 154 | if (configured) return configured; | 158 | platform === "win32" ? "workerd.exe" : "workerd"; |
| 159 | |||
| 160 | const detectLinuxMusl = (): boolean => { | ||
| 161 | if (process.platform !== "linux") return false; | ||
| 162 | if (existsSync("/etc/alpine-release")) return true; | ||
| 163 | try { | ||
| 164 | return readFileSync("/usr/bin/ldd", "utf8").toLowerCase().includes("musl"); | ||
| 165 | } catch { | ||
| 166 | return false; | ||
| 167 | } | ||
| 168 | }; | ||
| 169 | |||
| 170 | const workerdPlatformPackage = ( | ||
| 171 | platform: NodeJS.Platform = process.platform, | ||
| 172 | arch: NodeJS.Architecture = process.arch, | ||
| 173 | ): string | null => { | ||
| 174 | if (platform === "darwin" && arch === "arm64") return "@cloudflare/workerd-darwin-arm64"; | ||
| 175 | if (platform === "darwin" && arch === "x64") return "@cloudflare/workerd-darwin-64"; | ||
| 176 | if (platform === "linux" && detectLinuxMusl()) return null; | ||
| 177 | if (platform === "linux" && arch === "arm64") return "@cloudflare/workerd-linux-arm64"; | ||
| 178 | if (platform === "linux" && arch === "x64") return "@cloudflare/workerd-linux-64"; | ||
| 179 | if (platform === "win32" && arch === "x64") return "@cloudflare/workerd-windows-64"; | ||
| 180 | return null; | ||
| 181 | }; | ||
| 182 | |||
| 183 | const workerdPackageDirCandidates = (packageName: string): readonly string[] => { | ||
| 184 | const packageJson = `${packageName}/package.json`; | ||
| 185 | const resolved = new Set<string>(); | ||
| 186 | for (const req of [ | ||
| 187 | packageRequire, | ||
| 188 | createRequire(join(process.cwd(), "package.json")), | ||
| 189 | createRequire(join(packageDir, "package.json")), | ||
| 190 | ]) { | ||
| 191 | try { | ||
| 192 | resolved.add(dirname(req.resolve(packageJson))); | ||
| 193 | } catch { | ||
| 194 | // Try the next package-manager layout. | ||
| 195 | } | ||
| 196 | } | ||
| 197 | return [...resolved]; | ||
| 198 | }; | ||
| 199 | |||
| 200 | export const workerdBinaryCandidatesForTest = ( | ||
| 201 | roots: readonly string[], | ||
| 202 | platform: NodeJS.Platform, | ||
| 203 | arch: NodeJS.Architecture, | ||
| 204 | ): readonly string[] => { | ||
| 205 | const binary = workerdBinaryName(platform); | ||
| 206 | const platformPackage = workerdPlatformPackage(platform, arch); | ||
| 207 | return [ | ||
| 208 | ...roots.map((root) => join(root, "node_modules", "workerd", "bin", binary)), | ||
| 209 | ...(platformPackage | ||
| 210 | ? roots.flatMap((root) => [ | ||
| 211 | join(root, "node_modules", platformPackage, "bin", binary), | ||
| 212 | join(root, "node_modules", platformPackage, binary), | ||
| 213 | ]) | ||
| 214 | : []), | ||
| 215 | ]; | ||
| 216 | }; | ||
| 155 | 217 | ||
| 156 | const candidates = [ | 218 | const workerdBinaryCandidates = (): readonly string[] => { |
| 157 | join(packageDir, "node_modules", "workerd", "bin", "workerd"), | 219 | const roots = [packageDir, join(packageDir, "..", "..", ".."), process.cwd()]; |
| 158 | join(packageDir, "..", "..", "..", "node_modules", "workerd", "bin", "workerd"), | 220 | const platformPackage = workerdPlatformPackage(); |
| 159 | join(process.cwd(), "node_modules", "workerd", "bin", "workerd"), | 221 | return [ |
| 222 | ...(platformPackage | ||
| 223 | ? workerdPackageDirCandidates(platformPackage).flatMap((dir) => [ | ||
| 224 | join(dir, "bin", workerdBinaryName()), | ||
| 225 | join(dir, workerdBinaryName()), | ||
| 226 | ]) | ||
| 227 | : []), | ||
| 228 | ...workerdBinaryCandidatesForTest(roots, process.platform, process.arch), | ||
| 160 | ]; | 229 | ]; |
| 230 | }; | ||
| 231 | |||
| 232 | const findWorkerdBin = async (configured?: string): Promise<string> => { | ||
| 233 | if (configured) return configured; | ||
| 234 | if (process.env.EXECUTOR_WORKERD_BIN) return process.env.EXECUTOR_WORKERD_BIN; | ||
| 161 | 235 | ||
| 162 | for (const candidate of candidates) { | 236 | for (const candidate of workerdBinaryCandidates()) { |
| 163 | try { | 237 | try { |
| 164 | await access(candidate); | 238 | await access(candidate); |
| 165 | return candidate; | 239 | return candidate; |
| @@ -168,36 +242,41 @@ const findWorkerdBin = async (configured?: string): Promise<string> => { · 2 unmodified lines | |||
| 168 | } | 242 | } |
| 169 | } | 243 | } |
| 170 | 244 | ||
| 171 | return "workerd"; | 245 | if (workerdPlatformPackage() === null) throw new Error("workerd is unavailable on this platform"); |
| 172 | }; | 246 | const pathWorkerd = spawnSync("workerd", ["--version"], { |
| 173 | |||
| 174 | export const isWorkerdAvailable = (workerdBin?: string): boolean => { | ||
| 175 | const configured = workerdBin ?? "workerd"; | ||
| 176 | const result = spawnSync(configured, ["--version"], { | ||
| 177 | stdio: "ignore", | 247 | stdio: "ignore", |
| 178 | timeout: 5000, | 248 | timeout: 5000, |
| 179 | }); | 249 | }); |
| 180 | if (result.error === undefined && result.status === 0) return true; | 250 | if (pathWorkerd.error !== undefined || pathWorkerd.status !== 0) { |
| 251 | throw new Error("workerd is unavailable on this platform"); | ||
| 252 | } | ||
| 253 | return "workerd"; | ||
| 254 | }; | ||
| 181 | 255 | ||
| 182 | const local = spawnSync( | 256 | export const isWorkerdAvailable = (workerdBin?: string): boolean => { |
| 183 | join(packageDir, "node_modules", "workerd", "bin", "workerd"), | 257 | const configured = workerdBin ?? process.env.EXECUTOR_WORKERD_BIN; |
| 184 | ["--version"], | 258 | if (configured) { |
| 185 | { | 259 | const result = spawnSync(configured, ["--version"], { |
| 186 | stdio: "ignore", | 260 | stdio: "ignore", |
| 187 | timeout: 5000, | 261 | timeout: 5000, |
| 188 | }, | 262 | }); |
| 189 | ); | 263 | return result.error === undefined && result.status === 0; |
| 190 | if (local.error === undefined && local.status === 0) return true; | 264 | } |
| 191 | 265 | ||
| 192 | const workspace = spawnSync( | 266 | for (const candidate of workerdBinaryCandidates()) { |
| 193 | join(packageDir, "..", "..", "..", "node_modules", "workerd", "bin", "workerd"), | 267 | const result = spawnSync(candidate, ["--version"], { |
| 194 | ["--version"], | ||
| 195 | { | ||
| 196 | stdio: "ignore", | 268 | stdio: "ignore", |
| 197 | timeout: 5000, | 269 | timeout: 5000, |
| 198 | }, | 270 | }); |
| 199 | ); | 271 | if (result.error === undefined && result.status === 0) return true; |
| 200 | return workspace.error === undefined && workspace.status === 0; | 272 | } |
| 273 | |||
| 274 | if (workerdPlatformPackage() === null) return false; | ||
| 275 | const result = spawnSync("workerd", ["--version"], { | ||
| 276 | stdio: "ignore", | ||
| 277 | timeout: 5000, | ||
| 278 | }); | ||
| 279 | return result.error === undefined && result.status === 0; | ||
| 201 | }; | 280 | }; |
| 202 | 281 | ||
| 203 | const capnpString = (value: string): string => JSON.stringify(value); | 282 | const capnpString = (value: string): string => JSON.stringify(value); |
| @@ -225,9 +304,19 @@ const buildConfig = (input: { · 21 unmodified lines | |||
| 225 | return `( name = ${capnpString(name)}, ${field} = embed ${capnpString(name)} )`; | 304 | return `( name = ${capnpString(name)}, ${field} = embed ${capnpString(name)} )`; |
| 226 | }) | 305 | }) |
| 227 | .join(",\n "); | 306 | .join(",\n "); |
| 307 | // workerd's built-in `internet` service relies on the platform TLS trust | ||
| 308 | // store, which kj/tls cannot read on Windows ("unable to get local issuer | ||
| 309 | // certificate"). Declaring our own internet service with the runtime's | ||
| 310 | // bundled Mozilla roots gives every platform the same trust behavior. | ||
| 311 | const trustedCertificates = rootCertificates.map(capnpString).join(",\n "); | ||
| 228 | const services = [ | 312 | const services = [ |
| 229 | '( name = "main", worker = .mainWorker )', | 313 | '( name = "main", worker = .mainWorker )', |
| 230 | '( name = "blocked", worker = .blockedWorker )', | 314 | '( name = "blocked", worker = .blockedWorker )', |
| 315 | ...(input.globalOutbound === "internet" | ||
| 316 | ? [ | ||
| 317 | `( name = "internet", network = ( allow = ["public"], tlsOptions = ( trustedCertificates = [\n ${trustedCertificates}\n ] ) ) )`, | ||
| 318 | ] | ||
| 319 | : []), | ||
| 231 | ...(input.hostPort === null | 320 | ...(input.hostPort === null |
| 232 | ? [] | 321 | ? [] |
| 233 | : [`( name = "host", external = ( address = "127.0.0.1:${input.hostPort}", http = () ) )`]), | 322 | : [`( name = "host", external = ( address = "127.0.0.1:${input.hostPort}", http = () ) )`]), |
| @@ -426,7 +515,10 @@ export const createWorkerdModuleRunner = ( · 192 unmodified lines | |||
| 426 | }); | 515 | }); |
| 427 | proc.stderr.setEncoding("utf8"); | 516 | proc.stderr.setEncoding("utf8"); |
| 428 | proc.stdout.resume(); | 517 | proc.stdout.resume(); |
| 429 | proc.stderr.resume(); | 518 | let stderrTail = ""; |
| 519 | proc.stderr.on("data", (chunk: string) => { | ||
| 520 | stderrTail = (stderrTail + chunk).slice(-4000); | ||
| 521 | }); | ||
| 430 | proc.on("exit", (code, signal) => { | 522 | proc.on("exit", (code, signal) => { |
| 431 | lastExitAt = Date.now(); | 523 | lastExitAt = Date.now(); |
| 432 | if (processState?.proc === proc) { | 524 | if (processState?.proc === proc) { |
| @@ -444,7 +536,10 @@ export const createWorkerdModuleRunner = ( · 11 unmodified lines | |||
| 444 | await waitReady(listenPort, token, startupTimeoutMs); | 536 | await waitReady(listenPort, token, startupTimeoutMs); |
| 445 | } catch (cause) { | 537 | } catch (cause) { |
| 446 | await disposeState(true); | 538 | await disposeState(true); |
| 447 | throw cause; | 539 | const detail = stderrTail.trim(); |
| 540 | throw detail.length === 0 | ||
| 541 | ? cause | ||
| 542 | : new Error(`${normalizeError(cause).message}\nworkerd stderr:\n${detail}`); | ||
| 448 | } | 543 | } |
| 449 | })(); | 544 | })(); |
| 450 | 545 | ||
| @@ -20,6 +20,8 @@ · 19 unmodified lines | |||
| 20 | "./api": "./src/api.ts", | 20 | "./api": "./src/api.ts", |
| 21 | "./authoring": "./src/authoring.ts", | 21 | "./authoring": "./src/authoring.ts", |
| 22 | "./cloud": "./src/cloud.ts", | 22 | "./cloud": "./src/cloud.ts", |
| 23 | "./client": "./src/react/plugin-client.tsx", | ||
| 24 | "./react": "./src/react/index.ts", | ||
| 23 | "./selfhost": "./src/selfhost.ts", | 25 | "./selfhost": "./src/selfhost.ts", |
| 24 | "./testing": "./src/testing/index.ts" | 26 | "./testing": "./src/testing/index.ts" |
| 25 | }, | 27 | }, |
| @@ -39,14 +41,36 @@ · 13 unmodified lines | |||
| 39 | "zod": "4.3.6" | 41 | "zod": "4.3.6" |
| 40 | }, | 42 | }, |
| 41 | "devDependencies": { | 43 | "devDependencies": { |
| 44 | "@effect/atom-react": "catalog:", | ||
| 42 | "@effect/vitest": "catalog:", | 45 | "@effect/vitest": "catalog:", |
| 46 | "@executor-js/react": "workspace:*", | ||
| 43 | "@types/node": "catalog:", | 47 | "@types/node": "catalog:", |
| 48 | "@types/react": "catalog:", | ||
| 44 | "bun-types": "catalog:", | 49 | "bun-types": "catalog:", |
| 45 | "effect": "catalog:", | 50 | "effect": "catalog:", |
| 51 | "react": "catalog:", | ||
| 46 | "tsup": "catalog:", | 52 | "tsup": "catalog:", |
| 47 | "vitest": "catalog:" | 53 | "vitest": "catalog:" |
| 48 | }, | 54 | }, |
| 49 | "peerDependencies": { | 55 | "peerDependencies": { |
| 50 | "effect": "catalog:" | 56 | "@effect/atom-react": "catalog:", |
| 57 | "@executor-js/react": "workspace:*", | ||
| 58 | "@tanstack/react-router": "catalog:", | ||
| 59 | "effect": "catalog:", | ||
| 60 | "react": "catalog:" | ||
| 61 | }, | ||
| 62 | "peerDependenciesMeta": { | ||
| 63 | "@effect/atom-react": { | ||
| 64 | "optional": true | ||
| 65 | }, | ||
| 66 | "@executor-js/react": { | ||
| 67 | "optional": true | ||
| 68 | }, | ||
| 69 | "@tanstack/react-router": { | ||
| 70 | "optional": true | ||
| 71 | }, | ||
| 72 | "react": { | ||
| 73 | "optional": true | ||
| 74 | } | ||
| 51 | } | 75 | } |
| 52 | } | 76 | } |
| @@ -1,8 +1,8 @@ | |||
| 1 | import { definePlugin } from "@executor-js/sdk/core"; | 1 | import { definePlugin } from "@executor-js/sdk/core"; |
| 2 | 2 | ||
| 3 | import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin"; | 3 | import { makeAppsPlugin, type AppsPluginOptions } from "./plugin/apps-plugin"; |
| 4 | import { AppsExtensionService, AppsHandlers } from "./plugin/handlers"; | 4 | import { AppsExtensionService, appsHandlersForSourceKinds } from "./plugin/handlers"; |
| 5 | import { AppsGroup } from "./plugin/routes"; | 5 | import { appsGroupForSourceKinds } from "./plugin/routes"; |
| 6 | 6 | ||
| 7 | export { AppsGroup } from "./plugin/routes"; | 7 | export { AppsGroup } from "./plugin/routes"; |
| 8 | export { AppsHandlers, AppsExtensionService } from "./plugin/handlers"; | 8 | export { AppsHandlers, AppsExtensionService } from "./plugin/handlers"; |
| @@ -12,7 +12,7 @@ export { makeAppsPlugin, appsPlugin } from "./plugin/apps-plugin"; · 3 unmodified lines | |||
| 12 | 12 | ||
| 13 | export const appsHttpPlugin = definePlugin((options?: AppsPluginOptions) => ({ | 13 | export const appsHttpPlugin = definePlugin((options?: AppsPluginOptions) => ({ |
| 14 | ...makeAppsPlugin(options), | 14 | ...makeAppsPlugin(options), |
| 15 | routes: () => AppsGroup, | 15 | routes: () => appsGroupForSourceKinds(options?.sourceKinds), |
| 16 | handlers: () => AppsHandlers, | 16 | handlers: () => appsHandlersForSourceKinds(options?.sourceKinds), |
| 17 | extensionService: AppsExtensionService, | 17 | extensionService: AppsExtensionService, |
| 18 | })); | 18 | })); |
| @@ -1,7 +1,6 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error -- boundary: in-process dynamic-import backing converts JavaScript exceptions into typed AppExecutorError */ | 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error -- boundary: in-process dynamic-import backing converts JavaScript exceptions into typed AppExecutorError */ |
| 2 | import { Buffer } from "node:buffer"; | 2 | import { Buffer } from "node:buffer"; |
| 3 | import { Data, Effect, Predicate } from "effect"; | 3 | import { Data, Effect, Predicate } from "effect"; |
| 4 | import { z } from "zod"; | ||
| 5 | 4 | ||
| 6 | import { validToolKey } from "../pipeline/discover"; | 5 | import { validToolKey } from "../pipeline/discover"; |
| 7 | import { stableStringify } from "../pipeline/descriptor"; | 6 | import { stableStringify } from "../pipeline/descriptor"; |
| @@ -114,13 +113,39 @@ const validateStandard = async ( · 106 unmodified lines | |||
| 114 | return isRecord(result) && "value" in result ? result.value : value; | 113 | return isRecord(result) && "value" in result ? result.value : value; |
| 115 | }; | 114 | }; |
| 116 | 115 | ||
| 117 | const jsonSchemaFor = (schema: unknown): unknown => { | 116 | const jsonSchemaFor = ( |
| 117 | schema: unknown, | ||
| 118 | side: "input" | "output", | ||
| 119 | toolName: string, | ||
| 120 | sourcePath: string, | ||
| 121 | ): unknown => { | ||
| 118 | if (schema === undefined) return undefined; | 122 | if (schema === undefined) return undefined; |
| 119 | if (isRecord(schema) && isRecord(schema["~standard"])) { | 123 | if (isRecord(schema) && isRecord(schema["~standard"])) { |
| 124 | const jsonSchema = schema["~standard"].jsonSchema; | ||
| 125 | if (!isRecord(jsonSchema) || typeof jsonSchema[side] !== "function") { | ||
| 126 | throw new AppExecutorError({ | ||
| 127 | kind: "collect", | ||
| 128 | message: `tool "${toolName}" ${side} schema library does not expose the Standard Schema jsonSchema extension`, | ||
| 129 | diagnostics: [ | ||
| 130 | { | ||
| 131 | path: sourcePath, | ||
| 132 | message: `${side} schema library does not expose the Standard Schema jsonSchema extension`, | ||
| 133 | }, | ||
| 134 | ], | ||
| 135 | }); | ||
| 136 | } | ||
| 120 | try { | 137 | try { |
| 121 | return z.toJSONSchema(schema as unknown as z.ZodType); | 138 | const converted = jsonSchema[side]({ target: "draft-2020-12" }); |
| 122 | } catch { | 139 | if (!isRecord(converted)) return converted; |
| 123 | return {}; | 140 | const { $schema: _metaSchema, ...withoutMetaSchema } = converted; |
| 141 | return withoutMetaSchema; | ||
| 142 | } catch (cause) { | ||
| 143 | throw new AppExecutorError({ | ||
| 144 | kind: "collect", | ||
| 145 | message: `tool "${toolName}" ${side} schema conversion failed`, | ||
| 146 | diagnostics: [{ path: sourcePath, message: `${side} schema conversion failed` }], | ||
| 147 | cause, | ||
| 148 | }); | ||
| 124 | } | 149 | } |
| 125 | } | 150 | } |
| 126 | return schema; | 151 | return schema; |
| @@ -243,7 +268,7 @@ const collectFromExport = ( · 116 unmodified lines | |||
| 243 | sourcePath: string, | 268 | sourcePath: string, |
| 244 | ): CollectedModule => { | 269 | ): CollectedModule => { |
| 245 | if (isDefinedTool(exported)) { | 270 | if (isDefinedTool(exported)) { |
| 246 | const inputJsonSchema = jsonSchemaFor(exported.input); | 271 | const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath); |
| 247 | const integrations = collectIntegrations( | 272 | const integrations = collectIntegrations( |
| 248 | fileSlug, | 273 | fileSlug, |
| 249 | sourcePath, | 274 | sourcePath, |
| @@ -257,7 +282,7 @@ const collectFromExport = ( · 7 unmodified lines | |||
| 257 | description: exported.description, | 282 | description: exported.description, |
| 258 | integrations, | 283 | integrations, |
| 259 | inputSchema: projectInputSchema(inputJsonSchema, integrations), | 284 | inputSchema: projectInputSchema(inputJsonSchema, integrations), |
| 260 | outputSchema: jsonSchemaFor(exported.output), | 285 | outputSchema: jsonSchemaFor(exported.output, "output", fileSlug, sourcePath), |
| 261 | annotations: exported.annotations, | 286 | annotations: exported.annotations, |
| 262 | }, | 287 | }, |
| 263 | ], | 288 | ], |
| @@ -296,7 +321,7 @@ const collectFromExport = ( · 32 unmodified lines | |||
| 296 | diagnostics: [{ path: sourcePath, message: `duplicate tool name "${toolName}"` }], | 321 | diagnostics: [{ path: sourcePath, message: `duplicate tool name "${toolName}"` }], |
| 297 | }); | 322 | }); |
| 298 | } | 323 | } |
| 299 | const inputJsonSchema = jsonSchemaFor(value.input); | 324 | const inputJsonSchema = jsonSchemaFor(value.input, "input", toolName, sourcePath); |
| 300 | const integrations = collectIntegrations( | 325 | const integrations = collectIntegrations( |
| 301 | toolName, | 326 | toolName, |
| 302 | sourcePath, | 327 | sourcePath, |
| @@ -310,7 +335,7 @@ const collectFromExport = ( · 7 unmodified lines | |||
| 310 | description: value.description, | 335 | description: value.description, |
| 311 | integrations, | 336 | integrations, |
| 312 | inputSchema: projectInputSchema(inputJsonSchema, integrations), | 337 | inputSchema: projectInputSchema(inputJsonSchema, integrations), |
| 313 | outputSchema: jsonSchemaFor(value.output), | 338 | outputSchema: jsonSchemaFor(value.output, "output", toolName, sourcePath), |
| 314 | annotations: value.annotations, | 339 | annotations: value.annotations, |
| 315 | }); | 340 | }); |
| 316 | } | 341 | } |
| @@ -442,7 +467,12 @@ export const makeInProcessAppToolExecutor = (): AppToolExecutor => ({ · 125 unmodified lines | |||
| 442 | message: `tool not found in bundle: ${entry.toolName}`, | 467 | message: `tool not found in bundle: ${entry.toolName}`, |
| 443 | }); | 468 | }); |
| 444 | } | 469 | } |
| 445 | const inputJsonSchema = jsonSchemaFor(tool.input); | 470 | const inputJsonSchema = jsonSchemaFor( |
| 471 | tool.input, | ||
| 472 | "input", | ||
| 473 | entry.toolName, | ||
| 474 | entry.toolName, | ||
| 475 | ); | ||
| 446 | const integrations = collectIntegrations( | 476 | const integrations = collectIntegrations( |
| 447 | entry.toolName, | 477 | entry.toolName, |
| 448 | entry.toolName, | 478 | entry.toolName, |
| @@ -112,7 +112,22 @@ const integrationDeclaration = (value) => { · 111 unmodified lines | |||
| 112 | if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null; | 112 | if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null; |
| 113 | return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) }; | 113 | return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) }; |
| 114 | }; | 114 | }; |
| 115 | const jsonSchemaFor = (schema) => schema === undefined ? undefined : (isRecord(schema) && isRecord(schema["~standard"]) ? {} : schema); | 115 | const jsonSchemaFor = (schema, side, toolName, sourcePath) => { |
| 116 | if (schema === undefined) return undefined; | ||
| 117 | if (!isRecord(schema) || !isRecord(schema["~standard"])) return schema; | ||
| 118 | const jsonSchema = schema["~standard"].jsonSchema; | ||
| 119 | if (!isRecord(jsonSchema) || typeof jsonSchema[side] !== "function") { | ||
| 120 | throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema library does not expose the Standard Schema jsonSchema extension', [issue(sourcePath, side + " schema library does not expose the Standard Schema jsonSchema extension")]); | ||
| 121 | } | ||
| 122 | try { | ||
| 123 | const converted = jsonSchema[side]({ target: "draft-2020-12" }); | ||
| 124 | if (!isRecord(converted)) return converted; | ||
| 125 | const { $schema: _metaSchema, ...withoutMetaSchema } = converted; | ||
| 126 | return withoutMetaSchema; | ||
| 127 | } catch (cause) { | ||
| 128 | throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema conversion failed', [issue(sourcePath, side + " schema conversion failed")], cause); | ||
| 129 | } | ||
| 130 | }; | ||
| 116 | const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => { | 131 | const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => { |
| 117 | if (declarations === undefined) return {}; | 132 | if (declarations === undefined) return {}; |
| 118 | if (!isRecord(declarations)) throw fail("collect", 'tool "' + toolName + '" integrations must be a record', [issue(sourcePath, "integrations must be a record")]); | 133 | if (!isRecord(declarations)) throw fail("collect", 'tool "' + toolName + '" integrations must be a record', [issue(sourcePath, "integrations must be a record")]); |
| @@ -138,9 +153,9 @@ const projectInputSchema = (inputJsonSchema, integrations) => { · 19 unmodified lines | |||
| 138 | }; | 153 | }; |
| 139 | const collectFromExport = (exported, fileSlug, sourcePath) => { | 154 | const collectFromExport = (exported, fileSlug, sourcePath) => { |
| 140 | if (isDefinedTool(exported)) { | 155 | if (isDefinedTool(exported)) { |
| 141 | const inputJsonSchema = jsonSchemaFor(exported.input); | 156 | const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath); |
| 142 | const integrations = collectIntegrations(fileSlug, sourcePath, exported.integrations, inputJsonSchema); | 157 | const integrations = collectIntegrations(fileSlug, sourcePath, exported.integrations, inputJsonSchema); |
| 143 | return { tools: [{ toolName: fileSlug, description: exported.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(exported.output), annotations: exported.annotations }] }; | 158 | return { tools: [{ toolName: fileSlug, description: exported.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(exported.output, "output", fileSlug, sourcePath), annotations: exported.annotations }] }; |
| 144 | } | 159 | } |
| 145 | if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]); | 160 | if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]); |
| 146 | const tools = []; | 161 | const tools = []; |
| @@ -151,10 +166,10 @@ const collectFromExport = (exported, fileSlug, sourcePath) => { · 4 unmodified lines | |||
| 151 | if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]); | 166 | if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]); |
| 152 | const toolName = fileSlug + "__" + key; | 167 | const toolName = fileSlug + "__" + key; |
| 153 | if (seen.has(toolName)) throw fail("collect", 'duplicate tool name "' + toolName + '"', [issue(sourcePath, "duplicate tool name " + toolName)]); | 168 | if (seen.has(toolName)) throw fail("collect", 'duplicate tool name "' + toolName + '"', [issue(sourcePath, "duplicate tool name " + toolName)]); |
| 154 | const inputJsonSchema = jsonSchemaFor(value.input); | 169 | const inputJsonSchema = jsonSchemaFor(value.input, "input", toolName, sourcePath); |
| 155 | const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema); | 170 | const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema); |
| 156 | seen.add(toolName); | 171 | seen.add(toolName); |
| 157 | tools.push({ toolName, exportKey: key, description: value.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(value.output), annotations: value.annotations }); | 172 | tools.push({ toolName, exportKey: key, description: value.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(value.output, "output", toolName, sourcePath), annotations: value.annotations }); |
| 158 | } | 173 | } |
| 159 | if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]); | 174 | if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]); |
| 160 | return { tools }; | 175 | return { tools }; |
| @@ -220,7 +235,7 @@ export default class AppExecutor extends WorkerEntrypoint { · 59 unmodified lines | |||
| 220 | const exported = await resolveDefault(); | 235 | const exported = await resolveDefault(); |
| 221 | const tool = selectTool(exported, input.toolName); | 236 | const tool = selectTool(exported, input.toolName); |
| 222 | if (!isDefinedTool(tool)) throw fail("invoke", "tool not found in bundle: " + input.toolName); | 237 | if (!isDefinedTool(tool)) throw fail("invoke", "tool not found in bundle: " + input.toolName); |
| 223 | const inputJsonSchema = jsonSchemaFor(tool.input); | 238 | const inputJsonSchema = jsonSchemaFor(tool.input, "input", input.toolName, input.toolName); |
| 224 | const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema); | 239 | const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema); |
| 225 | const split = splitInvokeInput(bridge, input.toolName, input.input, integrations); | 240 | const split = splitInvokeInput(bridge, input.toolName, input.input, integrations); |
| 226 | const decoded = await validateStandard(tool.input, split.input, "input"); | 241 | const decoded = await validateStandard(tool.input, split.input, "input"); |
| @@ -1,4 +1,4 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error -- workerd boundary converts worker envelopes into typed AppExecutorError */ | 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error, executor/no-instanceof-error, executor/no-unknown-error-message -- workerd boundary converts worker envelopes and subprocess startup failures into typed AppExecutorError */ |
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| @@ -47,7 +47,7 @@ const mapCause = (cause: unknown, kind: AppExecutorError["kind"], message: strin · 42 unmodified lines | |||
| 47 | ? cause | 47 | ? cause |
| 48 | : new AppExecutorError({ | 48 | : new AppExecutorError({ |
| 49 | kind, | 49 | kind, |
| 50 | message, | 50 | message: `${message}: ${cause instanceof Error ? cause.message : String(cause)}`, |
| 51 | cause, | 51 | cause, |
| 52 | }); | 52 | }); |
| 53 | 53 | ||
| @@ -86,7 +86,22 @@ const integrationDeclaration = (value) => { · 32 unmodified lines | |||
| 86 | if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null; | 86 | if (typeof value.slug !== "string" || (value.mode !== "one" && value.mode !== "many")) return null; |
| 87 | return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) }; | 87 | return { slug: value.slug, mode: value.mode, ...(typeof value.description === "string" ? { description: value.description } : {}) }; |
| 88 | }; | 88 | }; |
| 89 | const jsonSchemaFor = (schema) => schema === undefined ? undefined : (isRecord(schema) && isRecord(schema["~standard"]) ? {} : schema); | 89 | const jsonSchemaFor = (schema, side, toolName, sourcePath) => { |
| 90 | if (schema === undefined) return undefined; | ||
| 91 | if (!isRecord(schema) || !isRecord(schema["~standard"])) return schema; | ||
| 92 | const jsonSchema = schema["~standard"].jsonSchema; | ||
| 93 | if (!isRecord(jsonSchema) || typeof jsonSchema[side] !== "function") { | ||
| 94 | throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema library does not expose the Standard Schema jsonSchema extension', [issue(sourcePath, side + " schema library does not expose the Standard Schema jsonSchema extension")]); | ||
| 95 | } | ||
| 96 | try { | ||
| 97 | const converted = jsonSchema[side]({ target: "draft-2020-12" }); | ||
| 98 | if (!isRecord(converted)) return converted; | ||
| 99 | const { $schema: _metaSchema, ...withoutMetaSchema } = converted; | ||
| 100 | return withoutMetaSchema; | ||
| 101 | } catch (cause) { | ||
| 102 | throw fail("collect", 'tool "' + toolName + '" ' + side + ' schema conversion failed', [issue(sourcePath, side + " schema conversion failed")], cause); | ||
| 103 | } | ||
| 104 | }; | ||
| 90 | const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => { | 105 | const collectIntegrations = (toolName, sourcePath, declarations, inputJsonSchema) => { |
| 91 | if (declarations === undefined) return {}; | 106 | if (declarations === undefined) return {}; |
| 92 | if (!isRecord(declarations)) throw fail("collect", 'tool "' + toolName + '" integrations must be a record', [issue(sourcePath, "integrations must be a record")]); | 107 | if (!isRecord(declarations)) throw fail("collect", 'tool "' + toolName + '" integrations must be a record', [issue(sourcePath, "integrations must be a record")]); |
| @@ -116,9 +131,9 @@ const projectInputSchema = (inputJsonSchema, integrations) => { · 23 unmodified lines | |||
| 116 | }; | 131 | }; |
| 117 | const collectFromExport = (exported, fileSlug, sourcePath) => { | 132 | const collectFromExport = (exported, fileSlug, sourcePath) => { |
| 118 | if (isDefinedTool(exported)) { | 133 | if (isDefinedTool(exported)) { |
| 119 | const inputJsonSchema = jsonSchemaFor(exported.input); | 134 | const inputJsonSchema = jsonSchemaFor(exported.input, "input", fileSlug, sourcePath); |
| 120 | const integrations = collectIntegrations(fileSlug, sourcePath, exported.integrations, inputJsonSchema); | 135 | const integrations = collectIntegrations(fileSlug, sourcePath, exported.integrations, inputJsonSchema); |
| 121 | return { tools: [{ toolName: fileSlug, description: exported.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(exported.output), annotations: exported.annotations }] }; | 136 | return { tools: [{ toolName: fileSlug, description: exported.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(exported.output, "output", fileSlug, sourcePath), annotations: exported.annotations }] }; |
| 122 | } | 137 | } |
| 123 | if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]); | 138 | if (!isRecord(exported)) throw fail("collect", "default export in " + sourcePath + " must be a tool, record, or factory", [issue(sourcePath, "unsupported default export")]); |
| 124 | const tools = []; | 139 | const tools = []; |
| @@ -129,10 +144,10 @@ const collectFromExport = (exported, fileSlug, sourcePath) => { · 4 unmodified lines | |||
| 129 | if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]); | 144 | if (!validToolKey(key)) throw fail("collect", 'record export key "' + key + '" is not a valid tool slug', [issue(sourcePath, "invalid record key " + key)]); |
| 130 | const toolName = fileSlug + "__" + key; | 145 | const toolName = fileSlug + "__" + key; |
| 131 | if (seen.has(toolName)) throw fail("collect", 'duplicate tool name "' + toolName + '"', [issue(sourcePath, "duplicate tool name " + toolName)]); | 146 | if (seen.has(toolName)) throw fail("collect", 'duplicate tool name "' + toolName + '"', [issue(sourcePath, "duplicate tool name " + toolName)]); |
| 132 | const inputJsonSchema = jsonSchemaFor(value.input); | 147 | const inputJsonSchema = jsonSchemaFor(value.input, "input", toolName, sourcePath); |
| 133 | const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema); | 148 | const integrations = collectIntegrations(toolName, sourcePath, value.integrations, inputJsonSchema); |
| 134 | seen.add(toolName); | 149 | seen.add(toolName); |
| 135 | tools.push({ toolName, exportKey: key, description: value.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(value.output), annotations: value.annotations }); | 150 | tools.push({ toolName, exportKey: key, description: value.description, integrations, inputSchema: projectInputSchema(inputJsonSchema, integrations), outputSchema: jsonSchemaFor(value.output, "output", toolName, sourcePath), annotations: value.annotations }); |
| 136 | } | 151 | } |
| 137 | if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]); | 152 | if (tools.length === 0) throw fail("collect", "record export in " + sourcePath + " contains no defineTool entries", [issue(sourcePath, "no tools found")]); |
| 138 | return { tools }; | 153 | return { tools }; |
| @@ -208,7 +223,7 @@ export default { · 69 unmodified lines | |||
| 208 | const exported = await resolveDefault(); | 223 | const exported = await resolveDefault(); |
| 209 | const tool = selectTool(exported, input.toolName); | 224 | const tool = selectTool(exported, input.toolName); |
| 210 | if (!isDefinedTool(tool)) throw fail("invoke", "tool not found in bundle: " + input.toolName); | 225 | if (!isDefinedTool(tool)) throw fail("invoke", "tool not found in bundle: " + input.toolName); |
| 211 | const inputJsonSchema = jsonSchemaFor(tool.input); | 226 | const inputJsonSchema = jsonSchemaFor(tool.input, "input", input.toolName, input.toolName); |
| 212 | const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema); | 227 | const integrations = collectIntegrations(input.toolName, input.toolName, tool.integrations, inputJsonSchema); |
| 213 | const split = splitInvokeInput(env, input.toolName, input.input, integrations); | 228 | const split = splitInvokeInput(env, input.toolName, input.input, integrations); |
| 214 | const decoded = await validateStandard(tool.input, split.input, "input"); | 229 | const decoded = await validateStandard(tool.input, split.input, "input"); |
| @@ -136,12 +136,18 @@ export const isPrivateGitHost = (host: string): boolean => { · 135 unmodified lines | |||
| 136 | 136 | ||
| 137 | export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => { | 137 | export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => { |
| 138 | const url = new URL(input); | 138 | const url = new URL(input); |
| 139 | if (url.protocol !== "https:") throw new Error("git source URL must use https"); | 139 | const privateHost = isPrivateGitHost(url.hostname); |
| 140 | if ( | ||
| 141 | url.protocol !== "https:" && | ||
| 142 | !(url.protocol === "http:" && policy.allowPrivateHosts === true && privateHost) | ||
| 143 | ) { | ||
| 144 | throw new Error("git source URL must use https"); | ||
| 145 | } | ||
| 140 | if (url.username || url.password) | 146 | if (url.username || url.password) |
| 141 | throw new Error("git source URL must not include embedded credentials"); | 147 | throw new Error("git source URL must not include embedded credentials"); |
| 142 | if (hasTokenLikeQuery(url)) | 148 | if (hasTokenLikeQuery(url)) |
| 143 | throw new Error("git source URL must not include token query parameters"); | 149 | throw new Error("git source URL must not include token query parameters"); |
| 144 | if (policy.allowPrivateHosts !== true && isPrivateGitHost(url.hostname)) { | 150 | if (policy.allowPrivateHosts !== true && privateHost) { |
| 145 | throw new Error("git source URL host is not allowed"); | 151 | throw new Error("git source URL host is not allowed"); |
| 146 | } | 152 | } |
| 147 | return url; | 153 | return url; |
| @@ -29,6 +29,15 @@ export interface DiscoverResult { · 28 unmodified lines | |||
| 29 | const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; | 29 | const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; |
| 30 | const DEFERRED_RE = /^(workflows|ui|skills)\//; | 30 | const DEFERRED_RE = /^(workflows|ui|skills)\//; |
| 31 | 31 | ||
| 32 | /** Reserved top-level app source folders whose contents are deferred by discovery. */ | ||
| 33 | export const RESERVED_ARTIFACT_DIRS = ["workflows", "ui", "skills"] as const; | ||
| 34 | |||
| 35 | /** Returns the discovered tool key for a path when it matches the canonical tool layout. */ | ||
| 36 | export const toolKeyFromPath = (path: string): string | null => path.match(TOOL_RE)?.[1] ?? null; | ||
| 37 | |||
| 38 | /** Returns true when a path is under a reserved app source folder. */ | ||
| 39 | export const isReservedArtifactPath = (path: string): boolean => DEFERRED_RE.test(path); | ||
| 40 | |||
| 32 | export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key); | 41 | export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key); |
| 33 | 42 | ||
| 34 | export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | PublishError => { | 43 | export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | PublishError => { |
| @@ -39,9 +48,9 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 4 unmodified lines | |||
| 39 | 48 | ||
| 40 | for (const [path] of files) { | 49 | for (const [path] of files) { |
| 41 | if (path === "executor.json") continue; | 50 | if (path === "executor.json") continue; |
| 42 | const tool = path.match(TOOL_RE); | 51 | const tool = toolKeyFromPath(path); |
| 43 | if (tool) { | 52 | if (tool) { |
| 44 | const name = tool[1]!; | 53 | const name = tool; |
| 45 | if (seen.has(name)) { | 54 | if (seen.has(name)) { |
| 46 | diagnostics.push({ path, message: `duplicate tool identity: ${name}` }); | 55 | diagnostics.push({ path, message: `duplicate tool identity: ${name}` }); |
| 47 | } else { | 56 | } else { |
| @@ -54,7 +63,7 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 6 unmodified lines | |||
| 54 | diagnostics.push({ path, message: "file does not match the expected layout for tools/" }); | 63 | diagnostics.push({ path, message: "file does not match the expected layout for tools/" }); |
| 55 | continue; | 64 | continue; |
| 56 | } | 65 | } |
| 57 | if (DEFERRED_RE.test(path)) skipped.push({ path, reason: "not supported yet" }); | 66 | if (isReservedArtifactPath(path)) skipped.push({ path, reason: "not supported yet" }); |
| 58 | } | 67 | } |
| 59 | 68 | ||
| 60 | if (diagnostics.length > 0) { | 69 | if (diagnostics.length > 0) { |
| @@ -58,7 +58,18 @@ const makeMemoryStore = (): AppsStore & { · 57 unmodified lines | |||
| 58 | for (const key of rows.keys()) rows.delete(key); | 58 | for (const key of rows.keys()) rows.delete(key); |
| 59 | for (const item of next.tools) rows.set(item.name, item); | 59 | for (const item of next.tools) rows.set(item.name, item); |
| 60 | }), | 60 | }), |
| 61 | listActiveTools: () => Effect.sync(() => descriptor?.tools ?? []), | 61 | removePublished: (app) => |
| 62 | Effect.sync(() => { | ||
| 63 | if (descriptor?.app === app) { | ||
| 64 | descriptor = null; | ||
| 65 | descriptorKey = null; | ||
| 66 | } | ||
| 67 | for (const key of rows.keys()) rows.delete(key); | ||
| 68 | }), | ||
| 69 | listActiveTools: () => | ||
| 70 | Effect.sync( | ||
| 71 | () => descriptor?.tools.map((tool) => ({ ...tool, app: descriptor?.app ?? "" })) ?? [], | ||
| 72 | ), | ||
| 62 | getTool: (name) => | 73 | getTool: (name) => |
| 63 | Effect.sync(() => { | 74 | Effect.sync(() => { |
| 64 | const row = descriptor?.tools.find((item) => item.name === name); | 75 | const row = descriptor?.tools.find((item) => item.name === name); |
| @@ -75,6 +86,23 @@ const makeMemoryStore = (): AppsStore & { · 10 unmodified lines | |||
| 75 | } | 86 | } |
| 76 | : null; | 87 | : null; |
| 77 | }), | 88 | }), |
| 89 | getToolForApp: (app, name) => | ||
| 90 | Effect.sync(() => { | ||
| 91 | const row = | ||
| 92 | descriptor?.app === app ? descriptor.tools.find((item) => item.name === name) : undefined; | ||
| 93 | return row | ||
| 94 | ? { | ||
| 95 | app: descriptor!.app, | ||
| 96 | name: row.name, | ||
| 97 | bundleKey: row.bundleKey, | ||
| 98 | description: row.description, | ||
| 99 | inputSchema: row.inputSchema, | ||
| 100 | outputSchema: row.outputSchema, | ||
| 101 | integrations: row.integrations, | ||
| 102 | annotations: row.annotations, | ||
| 103 | } | ||
| 104 | : null; | ||
| 105 | }), | ||
| 78 | putSource: () => Effect.void, | 106 | putSource: () => Effect.void, |
| 79 | listSources: () => Effect.succeed([]), | 107 | listSources: () => Effect.succeed([]), |
| 80 | getSource: () => Effect.succeed(null), | 108 | getSource: () => Effect.succeed(null), |
| @@ -63,6 +63,11 @@ describe("worker-bundler backend", () => { · 62 unmodified lines | |||
| 63 | sourcePath: "tools/greet.ts", | 63 | sourcePath: "tools/greet.ts", |
| 64 | }); | 64 | }); |
| 65 | expect(collected.tools.map((tool) => tool.toolName)).toEqual(["greet"]); | 65 | expect(collected.tools.map((tool) => tool.toolName)).toEqual(["greet"]); |
| 66 | expect(collected.tools[0]?.inputSchema).toMatchObject({ | ||
| 67 | type: "object", | ||
| 68 | properties: { name: { type: "string" } }, | ||
| 69 | required: ["name"], | ||
| 70 | }); | ||
| 66 | const invoked = yield* executor.invoke( | 71 | const invoked = yield* executor.invoke( |
| 67 | bundled.code, | 72 | bundled.code, |
| 68 | { toolName: "greet" }, | 73 | { toolName: "greet" }, |
| @@ -1,4 +1,5 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-unknown-error-message, executor/no-error-constructor, executor/no-json-parse, executor/no-instanceof-tagged-error, executor/no-instanceof-error -- boundary: subprocess-backed bundler validates package JSON and converts worker failures into typed AppExecutorError */ | 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-unknown-error-message, executor/no-error-constructor, executor/no-json-parse, executor/no-instanceof-tagged-error, executor/no-instanceof-error -- boundary: subprocess-backed bundler validates package JSON and converts worker failures into typed AppExecutorError */ |
| 2 | import { existsSync } from "node:fs"; | ||
| 2 | import { readFile } from "node:fs/promises"; | 3 | import { readFile } from "node:fs/promises"; |
| 3 | import { dirname, join } from "node:path"; | 4 | import { dirname, join } from "node:path"; |
| 4 | import { fileURLToPath } from "node:url"; | 5 | import { fileURLToPath } from "node:url"; |
| @@ -7,7 +8,6 @@ import { · 2 unmodified lines | |||
| 7 | createWorkerdModuleRunner, | 8 | createWorkerdModuleRunner, |
| 8 | WORKERD_VERSION, | 9 | WORKERD_VERSION, |
| 9 | } from "@executor-js/runtime-workerd-subprocess"; | 10 | } from "@executor-js/runtime-workerd-subprocess"; |
| 10 | import { build } from "esbuild"; | ||
| 11 | import { Effect } from "effect"; | 11 | import { Effect } from "effect"; |
| 12 | 12 | ||
| 13 | import { AppExecutorError } from "../executor/app-tool-executor"; | 13 | import { AppExecutorError } from "../executor/app-tool-executor"; |
| @@ -18,7 +18,9 @@ import { WORKER_BUNDLER_ESBUILD_VERSION, WORKER_BUNDLER_VERSION } from "./worker · 4 unmodified lines | |||
| 18 | 18 | ||
| 19 | const textEncoder = new TextEncoder(); | 19 | const textEncoder = new TextEncoder(); |
| 20 | const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); | 20 | const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); |
| 21 | const workerBundlerDir = join(packageDir, "node_modules", "@cloudflare", "worker-bundler"); | 21 | const workerBundlerDir = |
| 22 | process.env.EXECUTOR_WORKER_BUNDLER_DIR ?? | ||
| 23 | join(packageDir, "node_modules", "@cloudflare", "worker-bundler"); | ||
| 22 | 24 | ||
| 23 | const executorAppSource = ` | 25 | const executorAppSource = ` |
| 24 | const makeIntegrationDeclaration = (state) => Object.freeze({ | 26 | const makeIntegrationDeclaration = (state) => Object.freeze({ |
| @@ -79,6 +81,10 @@ export default { · 54 unmodified lines | |||
| 79 | if (code === null) { | 81 | if (code === null) { |
| 80 | return json({ ok: false, message: "worker-bundler did not return JavaScript for " + result.mainModule }); | 82 | return json({ ok: false, message: "worker-bundler did not return JavaScript for " + result.mainModule }); |
| 81 | } | 83 | } |
| 84 | const installFailure = (result.warnings ?? []).find((warning) => /failed to install/i.test(String(warning))); | ||
| 85 | if (installFailure) { | ||
| 86 | return json({ ok: false, message: String(installFailure) }); | ||
| 87 | } | ||
| 82 | for (const [path, module] of Object.entries(result.modules)) { | 88 | for (const [path, module] of Object.entries(result.modules)) { |
| 83 | const source = moduleCode(module) ?? ""; | 89 | const source = moduleCode(module) ?? ""; |
| 84 | if (/\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(path) || /\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(source)) { | 90 | if (/\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(path) || /\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(source)) { |
| @@ -97,19 +103,24 @@ const bundledWorkerBundler = async (): Promise<{ · 12 unmodified lines | |||
| 97 | readonly source: string; | 103 | readonly source: string; |
| 98 | readonly wasm: Uint8Array; | 104 | readonly wasm: Uint8Array; |
| 99 | }> => { | 105 | }> => { |
| 100 | const entry = join(workerBundlerDir, "dist", "index.js"); | 106 | const distDir = join(workerBundlerDir, "dist"); |
| 101 | const [result, wasm] = await Promise.all([ | 107 | const bundledEntry = join(distDir, "index.bundled.js"); |
| 102 | build({ | 108 | const [sourceFromDisk, wasm] = await Promise.all([ |
| 103 | entryPoints: [entry], | 109 | existsSync(bundledEntry) ? readFile(bundledEntry, "utf8") : Promise.resolve(null), |
| 104 | bundle: true, | 110 | readFile(join(distDir, "esbuild.wasm")), |
| 105 | format: "esm", | ||
| 106 | platform: "browser", | ||
| 107 | write: false, | ||
| 108 | external: ["./esbuild.wasm"], | ||
| 109 | logLevel: "silent", | ||
| 110 | }), | ||
| 111 | readFile(join(workerBundlerDir, "dist", "esbuild.wasm")), | ||
| 112 | ]); | 111 | ]); |
| 112 | if (sourceFromDisk !== null) return { source: sourceFromDisk, wasm }; | ||
| 113 | |||
| 114 | const { build } = await import("esbuild"); | ||
| 115 | const result = await build({ | ||
| 116 | entryPoints: [join(distDir, "index.js")], | ||
| 117 | bundle: true, | ||
| 118 | format: "esm", | ||
| 119 | platform: "browser", | ||
| 120 | write: false, | ||
| 121 | external: ["./esbuild.wasm"], | ||
| 122 | logLevel: "silent", | ||
| 123 | }); | ||
| 113 | const source = result.outputFiles[0]?.text; | 124 | const source = result.outputFiles[0]?.text; |
| 114 | if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); | 125 | if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); |
| 115 | return { source, wasm }; | 126 | return { source, wasm }; |
| @@ -1,10 +1,12 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: test temporarily overrides global fetch and restores it in finally */ | 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: test temporarily overrides global fetch and restores it in finally */ |
| 2 | import { readFile } from "node:fs/promises"; | 2 | import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; |
| 3 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | 4 | import { join } from "node:path"; |
| 4 | 5 | ||
| 5 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect, Exit } from "effect"; | 7 | import { Effect, Exit } from "effect"; |
| 7 | import { ToolResult } from "@executor-js/sdk"; | 8 | import { IntegrationSlug, ToolResult, createExecutor, definePlugin } from "@executor-js/sdk"; |
| 9 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | ||
| 8 | 10 | ||
| 9 | import { FLUSH, pktLine } from "../git-client/pktline"; | 11 | import { FLUSH, pktLine } from "../git-client/pktline"; |
| 10 | import { bundleEntry } from "../pipeline/bundle"; | 12 | import { bundleEntry } from "../pipeline/bundle"; |
| @@ -19,6 +21,45 @@ const bundle = (source: string) => · 8 unmodified lines | |||
| 19 | entry: "tools/sync.ts", | 21 | entry: "tools/sync.ts", |
| 20 | }); | 22 | }); |
| 21 | 23 | ||
| 24 | const localAppDir = (toolName: string, message: string) => | ||
| 25 | Effect.acquireRelease( | ||
| 26 | Effect.promise(async () => { | ||
| 27 | const root = await mkdtemp(join(tmpdir(), "executor-apps-test-")); | ||
| 28 | await mkdir(join(root, "tools")); | ||
| 29 | await writeFile( | ||
| 30 | join(root, "tools", `${toolName}.ts`), | ||
| 31 | ` | ||
| 32 | import { z } from "zod"; | ||
| 33 | import { defineTool } from "executor:app"; | ||
| 34 | export default defineTool({ | ||
| 35 | name: "${toolName}", | ||
| 36 | description: "${message}", | ||
| 37 | input: z.object({}), | ||
| 38 | async handler() { | ||
| 39 | return { message: "${message}" }; | ||
| 40 | }, | ||
| 41 | }); | ||
| 42 | `, | ||
| 43 | ); | ||
| 44 | return root; | ||
| 45 | }), | ||
| 46 | (root) => Effect.promise(() => rm(root, { recursive: true, force: true })), | ||
| 47 | ); | ||
| 48 | |||
| 49 | const collidingPlugin = definePlugin(() => ({ | ||
| 50 | id: "collider", | ||
| 51 | storage: () => ({}), | ||
| 52 | extension: (ctx) => ({ | ||
| 53 | seed: () => | ||
| 54 | ctx.core.integrations.register({ | ||
| 55 | slug: IntegrationSlug.make("github"), | ||
| 56 | name: "GitHub", | ||
| 57 | description: "Existing integration", | ||
| 58 | config: {}, | ||
| 59 | }), | ||
| 60 | }), | ||
| 61 | }))(); | ||
| 62 | |||
| 22 | const concat = (parts: readonly Uint8Array[]): Uint8Array => { | 63 | const concat = (parts: readonly Uint8Array[]): Uint8Array => { |
| 23 | const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); | 64 | const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); |
| 24 | let offset = 0; | 65 | let offset = 0; |
| @@ -49,15 +90,14 @@ const sideBand = (bytes: Uint8Array): Uint8Array => { · 24 unmodified lines | |||
| 49 | 90 | ||
| 50 | const makeSyncStore = (): AppsStore & { | 91 | const makeSyncStore = (): AppsStore & { |
| 51 | readonly sources: Map<string, AppSourceRecord>; | 92 | readonly sources: Map<string, AppSourceRecord>; |
| 52 | readonly descriptor: () => AppDescriptor | null; | 93 | readonly descriptor: (app: string) => AppDescriptor | null; |
| 53 | } => { | 94 | } => { |
| 54 | const blobs = new Map<string, string>(); | 95 | const blobs = new Map<string, string>(); |
| 55 | const sources = new Map<string, AppSourceRecord>(); | 96 | const sources = new Map<string, AppSourceRecord>(); |
| 56 | let descriptor: AppDescriptor | null = null; | 97 | const descriptors = new Map<string, { descriptor: AppDescriptor; descriptorKey: string }>(); |
| 57 | let descriptorKey: string | null = null; | ||
| 58 | return { | 98 | return { |
| 59 | sources, | 99 | sources, |
| 60 | descriptor: () => descriptor, | 100 | descriptor: (app) => descriptors.get(app)?.descriptor ?? null, |
| 61 | putBlob: (body) => | 101 | putBlob: (body) => |
| 62 | Effect.sync(() => { | 102 | Effect.sync(() => { |
| 63 | const key = `blob:${blobs.size}`; | 103 | const key = `blob:${blobs.size}`; |
| @@ -65,31 +105,63 @@ const makeSyncStore = (): AppsStore & { · 1 unmodified line | |||
| 65 | return key; | 105 | return key; |
| 66 | }), | 106 | }), |
| 67 | getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null), | 107 | getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null), |
| 68 | getDescriptorRecord: () => | 108 | getDescriptorRecord: (app) => |
| 69 | Effect.sync(() => | 109 | Effect.sync(() => { |
| 70 | descriptor ? { sourceRef: descriptor.sourceRef, descriptorKey: descriptorKey ?? "" } : null, | 110 | const current = descriptors.get(app); |
| 71 | ), | 111 | return current |
| 112 | ? { | ||
| 113 | sourceRef: current.descriptor.sourceRef, | ||
| 114 | descriptorKey: current.descriptorKey, | ||
| 115 | } | ||
| 116 | : null; | ||
| 117 | }), | ||
| 72 | putPublished: (next, nextDescriptorKey) => | 118 | putPublished: (next, nextDescriptorKey) => |
| 73 | Effect.sync(() => { | 119 | Effect.sync(() => { |
| 74 | descriptor = next; | 120 | descriptors.set(next.app, { descriptor: next, descriptorKey: nextDescriptorKey }); |
| 75 | descriptorKey = nextDescriptorKey; | 121 | }), |
| 122 | removePublished: (app) => | ||
| 123 | Effect.sync(() => { | ||
| 124 | descriptors.delete(app); | ||
| 76 | }), | 125 | }), |
| 77 | listActiveTools: () => Effect.sync(() => descriptor?.tools ?? []), | 126 | listActiveTools: () => |
| 127 | Effect.sync(() => | ||
| 128 | [...descriptors.values()].flatMap(({ descriptor }) => | ||
| 129 | descriptor.tools.map((tool) => ({ ...tool, app: descriptor.app })), | ||
| 130 | ), | ||
| 131 | ), | ||
| 78 | getTool: (name) => | 132 | getTool: (name) => |
| 79 | Effect.sync(() => { | 133 | Effect.sync(() => { |
| 134 | const descriptor = [...descriptors.values()].find((entry) => | ||
| 135 | entry.descriptor.tools.some((item) => item.name === name), | ||
| 136 | )?.descriptor; | ||
| 80 | const tool = descriptor?.tools.find((item) => item.name === name); | 137 | const tool = descriptor?.tools.find((item) => item.name === name); |
| 81 | return tool | 138 | if (!descriptor || !tool) return null; |
| 82 | ? { | 139 | return { |
| 83 | app: descriptor!.app, | 140 | app: descriptor.app, |
| 84 | name: tool.name, | 141 | name: tool.name, |
| 85 | bundleKey: tool.bundleKey, | 142 | bundleKey: tool.bundleKey, |
| 86 | description: tool.description, | 143 | description: tool.description, |
| 87 | inputSchema: tool.inputSchema, | 144 | inputSchema: tool.inputSchema, |
| 88 | outputSchema: tool.outputSchema, | 145 | outputSchema: tool.outputSchema, |
| 89 | integrations: tool.integrations, | 146 | integrations: tool.integrations, |
| 90 | annotations: tool.annotations, | 147 | annotations: tool.annotations, |
| 91 | } | 148 | }; |
| 92 | : null; | 149 | }), |
| 150 | getToolForApp: (app, name) => | ||
| 151 | Effect.sync(() => { | ||
| 152 | const descriptor = descriptors.get(app)?.descriptor; | ||
| 153 | const tool = descriptor?.tools.find((item) => item.name === name); | ||
| 154 | if (!descriptor || !tool) return null; | ||
| 155 | return { | ||
| 156 | app: descriptor.app, | ||
| 157 | name: tool.name, | ||
| 158 | bundleKey: tool.bundleKey, | ||
| 159 | description: tool.description, | ||
| 160 | inputSchema: tool.inputSchema, | ||
| 161 | outputSchema: tool.outputSchema, | ||
| 162 | integrations: tool.integrations, | ||
| 163 | annotations: tool.annotations, | ||
| 164 | }; | ||
| 93 | }), | 165 | }), |
| 94 | putSource: (record) => | 166 | putSource: (record) => |
| 95 | Effect.sync(() => { | 167 | Effect.sync(() => { |
| @@ -145,6 +217,7 @@ const makeInvokeStore = (input: { · 49 unmodified lines | |||
| 145 | getBlob: () => Effect.succeed(input.bundle), | 217 | getBlob: () => Effect.succeed(input.bundle), |
| 146 | getDescriptorRecord: () => Effect.succeed(null), | 218 | getDescriptorRecord: () => Effect.succeed(null), |
| 147 | putPublished: () => Effect.void, | 219 | putPublished: () => Effect.void, |
| 220 | removePublished: () => Effect.void, | ||
| 148 | listActiveTools: () => Effect.succeed([]), | 221 | listActiveTools: () => Effect.succeed([]), |
| 149 | getTool: () => | 222 | getTool: () => |
| 150 | Effect.succeed({ | 223 | Effect.succeed({ |
| @@ -154,6 +227,14 @@ const makeInvokeStore = (input: { · 3 unmodified lines | |||
| 154 | description: "Sync", | 227 | description: "Sync", |
| 155 | integrations: input.integrations, | 228 | integrations: input.integrations, |
| 156 | }), | 229 | }), |
| 230 | getToolForApp: () => | ||
| 231 | Effect.succeed({ | ||
| 232 | app: "crm", | ||
| 233 | name: "sync", | ||
| 234 | bundleKey: "bundle", | ||
| 235 | description: "Sync", | ||
| 236 | integrations: input.integrations, | ||
| 237 | }), | ||
| 157 | putSource: () => Effect.void, | 238 | putSource: () => Effect.void, |
| 158 | listSources: () => Effect.succeed([]), | 239 | listSources: () => Effect.succeed([]), |
| 159 | getSource: () => Effect.succeed(null), | 240 | getSource: () => Effect.succeed(null), |
| @@ -190,6 +271,7 @@ describe("apps plugin schema projection", () => { · 30 unmodified lines | |||
| 190 | getBlob: () => Effect.succeed(null), | 271 | getBlob: () => Effect.succeed(null), |
| 191 | getDescriptorRecord: () => Effect.succeed(null), | 272 | getDescriptorRecord: () => Effect.succeed(null), |
| 192 | putPublished: () => Effect.void, | 273 | putPublished: () => Effect.void, |
| 274 | removePublished: () => Effect.void, | ||
| 193 | listActiveTools: () => Effect.succeed([]), | 275 | listActiveTools: () => Effect.succeed([]), |
| 194 | putSource: () => Effect.void, | 276 | putSource: () => Effect.void, |
| 195 | listSources: () => Effect.succeed([]), | 277 | listSources: () => Effect.succeed([]), |
| @@ -206,6 +288,17 @@ describe("apps plugin schema projection", () => { · 10 unmodified lines | |||
| 206 | inboxes: { slug: "gmail", mode: "many" }, | 288 | inboxes: { slug: "gmail", mode: "many" }, |
| 207 | }, | 289 | }, |
| 208 | }), | 290 | }), |
| 291 | getToolForApp: () => | ||
| 292 | Effect.succeed({ | ||
| 293 | app: "crm", | ||
| 294 | name: "sync", | ||
| 295 | bundleKey: "bundle", | ||
| 296 | description: "Sync", | ||
| 297 | integrations: { | ||
| 298 | crm: { slug: "dealcloud", mode: "one" }, | ||
| 299 | inboxes: { slug: "gmail", mode: "many" }, | ||
| 300 | }, | ||
| 301 | }), | ||
| 209 | }; | 302 | }; |
| 210 | const result = yield* projectAppsToolSchema( | 303 | const result = yield* projectAppsToolSchema( |
| 211 | { | 304 | { |
| @@ -219,6 +312,7 @@ describe("apps plugin schema projection", () => { · 7 unmodified lines | |||
| 219 | ), | 312 | ), |
| 220 | }, | 313 | }, |
| 221 | } as never, | 314 | } as never, |
| 315 | "crm", | ||
| 222 | "sync", | 316 | "sync", |
| 223 | { | 317 | { |
| 224 | type: "object", | 318 | type: "object", |
| @@ -233,15 +327,25 @@ describe("apps plugin schema projection", () => { · 8 unmodified lines | |||
| 233 | ); | 327 | ); |
| 234 | expect(result.inputSchema).toMatchObject({ | 328 | expect(result.inputSchema).toMatchObject({ |
| 235 | properties: { | 329 | properties: { |
| 236 | crm: { enum: ["tools.dealcloud.org.main"] }, | 330 | crm: { |
| 331 | enum: ["tools.dealcloud.org.main"], | ||
| 332 | default: "tools.dealcloud.org.main", | ||
| 333 | }, | ||
| 237 | inboxes: { | 334 | inboxes: { |
| 335 | default: ["tools.gmail.org.work", "tools.gmail.user.personal"], | ||
| 238 | items: { | 336 | items: { |
| 239 | enum: ["tools.gmail.org.work", "tools.gmail.user.personal"], | 337 | enum: ["tools.gmail.org.work", "tools.gmail.user.personal"], |
| 240 | }, | 338 | }, |
| 241 | }, | 339 | }, |
| 242 | }, | 340 | }, |
| 243 | required: ["crm", "inboxes"], | ||
| 244 | }); | 341 | }); |
| 342 | // The response schema requires JSON values: a `required` key holding | ||
| 343 | // undefined fails encoding (the /api/tools/schema 400), so the key must | ||
| 344 | // be absent entirely when all required fields were projected away. | ||
| 345 | expect("required" in (result.inputSchema as Record<string, unknown>)).toBe(false); | ||
| 346 | expect(Object.values(result.inputSchema as Record<string, unknown>).includes(undefined)).toBe( | ||
| 347 | false, | ||
| 348 | ); | ||
| 245 | }), | 349 | }), |
| 246 | ); | 350 | ); |
| 247 | }); | 351 | }); |
| @@ -303,6 +407,17 @@ describe("apps source sync", () => { · 55 unmodified lines | |||
| 303 | }), | 407 | }), |
| 304 | ); | 408 | ); |
| 305 | 409 | ||
| 410 | it.effect("rejects directory listing when local-directory sources are disabled", () => | ||
| 411 | Effect.gen(function* () { | ||
| 412 | const plugin = makeAppsPlugin({ sourceKinds: ["git"] }); | ||
| 413 | const extension = plugin.extension!({ | ||
| 414 | storage: makeSyncStore(), | ||
| 415 | } as never); | ||
| 416 | const exit = yield* Effect.exit(extension.listDirs({ path: "/tmp" })); | ||
| 417 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 418 | }), | ||
| 419 | ); | ||
| 420 | |||
| 306 | it.effect("syncs git sources, no-ops unchanged refs, and republishes changed refs", () => | 421 | it.effect("syncs git sources, no-ops unchanged refs, and republishes changed refs", () => |
| 307 | Effect.gen(function* () { | 422 | Effect.gen(function* () { |
| 308 | const fixture = yield* Effect.promise(() => fixtureFetch()); | 423 | const fixture = yield* Effect.promise(() => fixtureFetch()); |
| @@ -314,7 +429,8 @@ describe("apps source sync", () => { · 5 unmodified lines | |||
| 314 | executor: makeInProcessAppToolExecutor(), | 429 | executor: makeInProcessAppToolExecutor(), |
| 315 | allowPrivateGitHosts: true, | 430 | allowPrivateGitHosts: true, |
| 316 | }); | 431 | }); |
| 317 | let connectionExists = false; | 432 | const integrations = new Set<string>(); |
| 433 | const connections = new Set<string>(); | ||
| 318 | const extension = plugin.extension!({ | 434 | const extension = plugin.extension!({ |
| 319 | storage: store, | 435 | storage: store, |
| 320 | providers: { | 436 | providers: { |
| @@ -324,16 +440,31 @@ describe("apps source sync", () => { · 3 unmodified lines | |||
| 324 | }, | 440 | }, |
| 325 | core: { | 441 | core: { |
| 326 | integrations: { | 442 | integrations: { |
| 327 | register: () => Effect.void, | 443 | get: (slug: unknown) => |
| 444 | Effect.sync(() => | ||
| 445 | integrations.has(String(slug)) | ||
| 446 | ? ({ slug, kind: "apps", description: "", canRemove: true } as never) | ||
| 447 | : null, | ||
| 448 | ), | ||
| 449 | register: (input: { readonly slug: unknown }) => | ||
| 450 | Effect.sync(() => { | ||
| 451 | integrations.add(String(input.slug)); | ||
| 452 | }), | ||
| 453 | remove: (slug: unknown) => | ||
| 454 | Effect.sync(() => { | ||
| 455 | integrations.delete(String(slug)); | ||
| 456 | connections.delete(String(slug)); | ||
| 457 | }), | ||
| 328 | }, | 458 | }, |
| 329 | }, | 459 | }, |
| 330 | connections: { | 460 | connections: { |
| 331 | list: () => Effect.succeed([]), | 461 | list: () => Effect.succeed([]), |
| 332 | resolveValue: () => Effect.succeed(null), | 462 | resolveValue: () => Effect.succeed(null), |
| 333 | get: () => Effect.succeed(connectionExists ? ({} as never) : null), | 463 | get: ({ integration }: { readonly integration: unknown }) => |
| 334 | create: () => | 464 | Effect.succeed(connections.has(String(integration)) ? ({} as never) : null), |
| 465 | create: ({ integration }: { readonly integration: unknown }) => | ||
| 335 | Effect.sync(() => { | 466 | Effect.sync(() => { |
| 336 | connectionExists = true; | 467 | connections.add(String(integration)); |
| 337 | }), | 468 | }), |
| 338 | refresh: () => Effect.succeed([]), | 469 | refresh: () => Effect.succeed([]), |
| 339 | }, | 470 | }, |
| @@ -359,12 +490,215 @@ describe("apps source sync", () => { · 19 unmodified lines | |||
| 359 | expect(changed.status).toBe("published"); | 490 | expect(changed.status).toBe("published"); |
| 360 | expect(changed.sourceRef).not.toBe(first.sourceRef); | 491 | expect(changed.sourceRef).not.toBe(first.sourceRef); |
| 361 | expect(fixture.packRequests()).toBe(2); | 492 | expect(fixture.packRequests()).toBe(2); |
| 362 | expect(store.descriptor()?.sourceRef).toBe(changed.sourceRef); | 493 | expect(store.descriptor("fixture")?.sourceRef).toBe(changed.sourceRef); |
| 363 | } finally { | 494 | } finally { |
| 364 | globalThis.fetch = originalFetch; | 495 | globalThis.fetch = originalFetch; |
| 365 | } | 496 | } |
| 366 | }), | 497 | }), |
| 367 | ); | 498 | ); |
| 499 | |||
| 500 | it.effect("registers each app as its own integration with disjoint tool addresses", () => | ||
| 501 | Effect.scoped( | ||
| 502 | Effect.gen(function* () { | ||
| 503 | const alphaDir = yield* localAppDir("echo", "alpha"); | ||
| 504 | const betaDir = yield* localAppDir("echo", "beta"); | ||
| 505 | const executor = yield* createExecutor( | ||
| 506 | makeTestConfig({ | ||
| 507 | plugins: [ | ||
| 508 | memoryCredentialsPlugin(), | ||
| 509 | makeAppsPlugin({ sourceKinds: ["local-directory"] }), | ||
| 510 | ] as const, | ||
| 511 | }), | ||
| 512 | ); | ||
| 513 | |||
| 514 | yield* executor.apps.createSource({ | ||
| 515 | kind: "local-directory", | ||
| 516 | slug: "alpha-source", | ||
| 517 | app: "alpha-tools", | ||
| 518 | path: alphaDir, | ||
| 519 | }); | ||
| 520 | yield* executor.apps.createSource({ | ||
| 521 | kind: "local-directory", | ||
| 522 | slug: "beta-source", | ||
| 523 | app: "beta-tools", | ||
| 524 | path: betaDir, | ||
| 525 | }); | ||
| 526 | |||
| 527 | const alphaSync = yield* executor.apps.syncSource("alpha-source"); | ||
| 528 | const betaSync = yield* executor.apps.syncSource("beta-source"); | ||
| 529 | expect(alphaSync.status).toBe("published"); | ||
| 530 | expect(betaSync.status).toBe("published"); | ||
| 531 | |||
| 532 | const alphaIntegration = yield* executor.integrations.get( | ||
| 533 | IntegrationSlug.make("alpha-tools"), | ||
| 534 | ); | ||
| 535 | const betaIntegration = yield* executor.integrations.get( | ||
| 536 | IntegrationSlug.make("beta-tools"), | ||
| 537 | ); | ||
| 538 | expect(alphaIntegration?.name).toBe("alpha-tools"); | ||
| 539 | expect(betaIntegration?.name).toBe("beta-tools"); | ||
| 540 | |||
| 541 | const alphaTools = yield* executor.tools.list({ | ||
| 542 | integration: IntegrationSlug.make("alpha-tools"), | ||
| 543 | }); | ||
| 544 | const betaTools = yield* executor.tools.list({ | ||
| 545 | integration: IntegrationSlug.make("beta-tools"), | ||
| 546 | }); | ||
| 547 | expect(alphaTools.map((tool) => String(tool.address))).toEqual([ | ||
| 548 | "tools.alpha-tools.org.published.echo", | ||
| 549 | ]); | ||
| 550 | expect(betaTools.map((tool) => String(tool.address))).toEqual([ | ||
| 551 | "tools.beta-tools.org.published.echo", | ||
| 552 | ]); | ||
| 553 | }), | ||
| 554 | ), | ||
| 555 | ); | ||
| 556 | |||
| 557 | it.effect("rejects an app slug that collides with a non-app integration before publishing", () => | ||
| 558 | Effect.scoped( | ||
| 559 | Effect.gen(function* () { | ||
| 560 | const sourceDir = yield* localAppDir("echo", "collision"); | ||
| 561 | const executor = yield* createExecutor( | ||
| 562 | makeTestConfig({ | ||
| 563 | plugins: [ | ||
| 564 | memoryCredentialsPlugin(), | ||
| 565 | collidingPlugin, | ||
| 566 | makeAppsPlugin({ sourceKinds: ["local-directory"] }), | ||
| 567 | ] as const, | ||
| 568 | }), | ||
| 569 | ); | ||
| 570 | yield* executor.collider.seed(); | ||
| 571 | yield* executor.apps.createSource({ | ||
| 572 | kind: "local-directory", | ||
| 573 | slug: "github-source", | ||
| 574 | app: "github", | ||
| 575 | path: sourceDir, | ||
| 576 | }); | ||
| 577 | |||
| 578 | const result = yield* executor.apps.syncSource("github-source"); | ||
| 579 | expect(result.status).toBe("failed"); | ||
| 580 | expect(result.errors?.[0]?.message).toContain("github"); | ||
| 581 | const tools = yield* executor.tools.list({ integration: IntegrationSlug.make("github") }); | ||
| 582 | expect(tools).toEqual([]); | ||
| 583 | }), | ||
| 584 | ), | ||
| 585 | ); | ||
| 586 | |||
| 587 | it.effect("keeps the app source panel reachable after a source-stage sync failure", () => | ||
| 588 | Effect.scoped( | ||
| 589 | Effect.gen(function* () { | ||
| 590 | const executor = yield* createExecutor( | ||
| 591 | makeTestConfig({ | ||
| 592 | plugins: [ | ||
| 593 | memoryCredentialsPlugin(), | ||
| 594 | makeAppsPlugin({ sourceKinds: ["local-directory"] }), | ||
| 595 | ] as const, | ||
| 596 | }), | ||
| 597 | ); | ||
| 598 | yield* executor.apps.createSource({ | ||
| 599 | kind: "local-directory", | ||
| 600 | slug: "missing-source", | ||
| 601 | app: "missing-tools", | ||
| 602 | path: "/definitely/missing/executor-app-source", | ||
| 603 | }); | ||
| 604 | |||
| 605 | const result = yield* executor.apps.syncSource("missing-source"); | ||
| 606 | expect(result.status).toBe("failed"); | ||
| 607 | expect(result.errors?.[0]?.stage).toBe("source"); | ||
| 608 | const integration = yield* executor.integrations.get(IntegrationSlug.make("missing-tools")); | ||
| 609 | expect(integration?.kind).toBe("apps"); | ||
| 610 | expect( | ||
| 611 | yield* executor.tools.list({ integration: IntegrationSlug.make("missing-tools") }), | ||
| 612 | ).toEqual([]); | ||
| 613 | }), | ||
| 614 | ), | ||
| 615 | ); | ||
| 616 | |||
| 617 | it.effect("rejects two app sources with the same app slug", () => | ||
| 618 | Effect.scoped( | ||
| 619 | Effect.gen(function* () { | ||
| 620 | const firstDir = yield* localAppDir("first", "first"); | ||
| 621 | const secondDir = yield* localAppDir("second", "second"); | ||
| 622 | const executor = yield* createExecutor( | ||
| 623 | makeTestConfig({ | ||
| 624 | plugins: [ | ||
| 625 | memoryCredentialsPlugin(), | ||
| 626 | makeAppsPlugin({ sourceKinds: ["local-directory"] }), | ||
| 627 | ] as const, | ||
| 628 | }), | ||
| 629 | ); | ||
| 630 | yield* executor.apps.createSource({ | ||
| 631 | kind: "local-directory", | ||
| 632 | slug: "first-source", | ||
| 633 | app: "shared-app", | ||
| 634 | path: firstDir, | ||
| 635 | }); | ||
| 636 | yield* executor.apps.createSource({ | ||
| 637 | kind: "local-directory", | ||
| 638 | slug: "second-source", | ||
| 639 | app: "shared-app", | ||
| 640 | path: secondDir, | ||
| 641 | }); | ||
| 642 | |||
| 643 | const result = yield* executor.apps.syncSource("first-source"); | ||
| 644 | expect(result.status).toBe("failed"); | ||
| 645 | expect(result.errors?.[0]?.message).toContain("shared-app"); | ||
| 646 | const tools = yield* executor.tools.list({ | ||
| 647 | integration: IntegrationSlug.make("shared-app"), | ||
| 648 | }); | ||
| 649 | expect(tools).toEqual([]); | ||
| 650 | }), | ||
| 651 | ), | ||
| 652 | ); | ||
| 653 | |||
| 654 | it.effect("deleteSource removes the app integration and republish recreates it", () => | ||
| 655 | Effect.scoped( | ||
| 656 | Effect.gen(function* () { | ||
| 657 | const sourceDir = yield* localAppDir("echo", "delete"); | ||
| 658 | const executor = yield* createExecutor( | ||
| 659 | makeTestConfig({ | ||
| 660 | plugins: [ | ||
| 661 | memoryCredentialsPlugin(), | ||
| 662 | makeAppsPlugin({ sourceKinds: ["local-directory"] }), | ||
| 663 | ] as const, | ||
| 664 | }), | ||
| 665 | ); | ||
| 666 | yield* executor.apps.createSource({ | ||
| 667 | kind: "local-directory", | ||
| 668 | slug: "delete-source", | ||
| 669 | app: "delete-tools", | ||
| 670 | path: sourceDir, | ||
| 671 | }); | ||
| 672 | const firstSync = yield* executor.apps.syncSource("delete-source"); | ||
| 673 | expect(firstSync.status).toBe("published"); | ||
| 674 | expect( | ||
| 675 | (yield* executor.tools.list({ integration: IntegrationSlug.make("delete-tools") })).map( | ||
| 676 | (tool) => String(tool.address), | ||
| 677 | ), | ||
| 678 | ).toEqual(["tools.delete-tools.org.published.echo"]); | ||
| 679 | |||
| 680 | yield* executor.apps.deleteSource("delete-source"); | ||
| 681 | expect(yield* executor.integrations.get(IntegrationSlug.make("delete-tools"))).toBe(null); | ||
| 682 | expect( | ||
| 683 | yield* executor.tools.list({ integration: IntegrationSlug.make("delete-tools") }), | ||
| 684 | ).toEqual([]); | ||
| 685 | |||
| 686 | yield* executor.apps.createSource({ | ||
| 687 | kind: "local-directory", | ||
| 688 | slug: "delete-source", | ||
| 689 | app: "delete-tools", | ||
| 690 | path: sourceDir, | ||
| 691 | }); | ||
| 692 | const secondSync = yield* executor.apps.syncSource("delete-source"); | ||
| 693 | expect(secondSync.status).toBe("published"); | ||
| 694 | expect( | ||
| 695 | (yield* executor.tools.list({ integration: IntegrationSlug.make("delete-tools") })).map( | ||
| 696 | (tool) => String(tool.address), | ||
| 697 | ), | ||
| 698 | ).toEqual(["tools.delete-tools.org.published.echo"]); | ||
| 699 | }), | ||
| 700 | ), | ||
| 701 | ); | ||
| 368 | }); | 702 | }); |
| 369 | 703 | ||
| 370 | describe("apps plugin invocation", () => { | 704 | describe("apps plugin invocation", () => { |
| @@ -392,7 +726,7 @@ describe("apps plugin invocation", () => { · 21 unmodified lines | |||
| 392 | ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }), | 726 | ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }), |
| 393 | ), | 727 | ), |
| 394 | }), | 728 | }), |
| 395 | toolRow: { name: "sync" }, | 729 | toolRow: { integration: "crm", name: "sync" }, |
| 396 | args: { crm: "tools.dealcloud.org.main" }, | 730 | args: { crm: "tools.dealcloud.org.main" }, |
| 397 | } as never); | 731 | } as never); |
| 398 | expect(result).toMatchObject({ | 732 | expect(result).toMatchObject({ |
| @@ -439,7 +773,7 @@ describe("apps plugin invocation", () => { · 40 unmodified lines | |||
| 439 | ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }), | 773 | ToolResult.fail({ code: "upstream_failed", message: "CRM unavailable" }), |
| 440 | ), | 774 | ), |
| 441 | }), | 775 | }), |
| 442 | toolRow: { name: "sync" }, | 776 | toolRow: { integration: "crm", name: "sync" }, |
| 443 | args: { crm: "tools.dealcloud.org.main" }, | 777 | args: { crm: "tools.dealcloud.org.main" }, |
| 444 | } as never); | 778 | } as never); |
| 445 | expect(result).toEqual({ fallback: true }); | 779 | expect(result).toEqual({ fallback: true }); |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | ToolName, | 9 | ToolName, |
| 10 | ToolResult, | 10 | ToolResult, |
| 11 | definePlugin, | 11 | definePlugin, |
| 12 | IntegrationAlreadyExistsError, | ||
| 12 | type PluginCtx, | 13 | type PluginCtx, |
| 13 | type ToolDef, | 14 | type ToolDef, |
| 14 | } from "@executor-js/sdk"; | 15 | } from "@executor-js/sdk"; |
| @@ -33,11 +34,13 @@ import { · 18 unmodified lines | |||
| 33 | type SyncDiagnostic, | 34 | type SyncDiagnostic, |
| 34 | } from "../source/app-source"; | 35 | } from "../source/app-source"; |
| 35 | import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "../source/git-source"; | 36 | import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "../source/git-source"; |
| 36 | import { fetchLocalDirectoryAppSource } from "../source/local-directory-source"; | 37 | import { |
| 38 | fetchLocalDirectoryAppSource, | ||
| 39 | listLocalDirectoryDirs, | ||
| 40 | } from "../source/local-directory-source"; | ||
| 37 | import { AppSourceError, type AppSourceSnapshot } from "../source/app-source"; | 41 | import { AppSourceError, type AppSourceSnapshot } from "../source/app-source"; |
| 38 | import type { PublishError } from "../pipeline/publish"; | 42 | import type { PublishError } from "../pipeline/publish"; |
| 39 | 43 | ||
| 40 | const APPS_INTEGRATION = IntegrationSlug.make("apps"); | ||
| 41 | const APPS_CONNECTION = ConnectionName.make("published"); | 44 | const APPS_CONNECTION = ConnectionName.make("published"); |
| 42 | const APPS_NO_AUTH = AuthTemplateSlug.make("none"); | 45 | const APPS_NO_AUTH = AuthTemplateSlug.make("none"); |
| 43 | 46 | ||
| @@ -45,6 +48,10 @@ class AppPluginError extends Data.TaggedError("AppPluginError")<{ · 1 unmodified line | |||
| 45 | readonly message: string; | 48 | readonly message: string; |
| 46 | }> {} | 49 | }> {} |
| 47 | 50 | ||
| 51 | class AppSlugConflictError extends Data.TaggedError("AppSlugConflictError")<{ | ||
| 52 | readonly app: string; | ||
| 53 | }> {} | ||
| 54 | |||
| 48 | interface ProjectedToolSchema { | 55 | interface ProjectedToolSchema { |
| 49 | readonly inputSchema?: unknown; | 56 | readonly inputSchema?: unknown; |
| 50 | readonly outputSchema?: unknown; | 57 | readonly outputSchema?: unknown; |
| @@ -138,53 +145,109 @@ const sourceConfig = (input: CreateAppSourceInput): AppSourceConfig => { · 87 unmodified lines | |||
| 138 | return { kind: "local-directory", path: input.path }; | 145 | return { kind: "local-directory", path: input.path }; |
| 139 | }; | 146 | }; |
| 140 | 147 | ||
| 141 | const ensureAppsCatalogConnection = (ctx: PluginCtx<AppsStore>): Effect.Effect<void, unknown> => | 148 | const appDescription = (record: AppSourceRecord, description?: string): string => { |
| 149 | if (description) return description; | ||
| 150 | if (record.description) return record.description; | ||
| 151 | if (record.config.kind === "git") return `Custom tools from ${record.config.url}`; | ||
| 152 | return `Custom tools from ${record.config.path}`; | ||
| 153 | }; | ||
| 154 | |||
| 155 | const ensureAppIntegration = ( | ||
| 156 | ctx: PluginCtx<AppsStore>, | ||
| 157 | record: AppSourceRecord, | ||
| 158 | description?: string, | ||
| 159 | ): Effect.Effect<void, unknown> => | ||
| 142 | Effect.gen(function* () { | 160 | Effect.gen(function* () { |
| 161 | const appSlug = IntegrationSlug.make(record.app); | ||
| 162 | const existing = yield* ctx.core.integrations.get(appSlug); | ||
| 163 | if (existing && existing.kind !== "apps") return; | ||
| 143 | yield* ctx.core.integrations.register({ | 164 | yield* ctx.core.integrations.register({ |
| 144 | slug: APPS_INTEGRATION, | 165 | slug: appSlug, |
| 145 | name: "Apps", | 166 | name: record.app, |
| 146 | description: "Published app tools", | 167 | description: appDescription(record, description), |
| 147 | config: {}, | 168 | config: {}, |
| 148 | }); | 169 | }); |
| 170 | }); | ||
| 171 | |||
| 172 | const ensureAppConnection = ( | ||
| 173 | ctx: PluginCtx<AppsStore>, | ||
| 174 | record: AppSourceRecord, | ||
| 175 | description?: string, | ||
| 176 | ): Effect.Effect<void, unknown> => | ||
| 177 | Effect.gen(function* () { | ||
| 178 | const appSlug = IntegrationSlug.make(record.app); | ||
| 179 | yield* ensureAppIntegration(ctx, record, description); | ||
| 149 | const existing = yield* ctx.connections.get({ | 180 | const existing = yield* ctx.connections.get({ |
| 150 | owner: "org", | 181 | owner: "org", |
| 151 | integration: APPS_INTEGRATION, | 182 | integration: appSlug, |
| 152 | name: APPS_CONNECTION, | 183 | name: APPS_CONNECTION, |
| 153 | }); | 184 | }); |
| 154 | if (existing) { | 185 | if (existing) { |
| 155 | yield* ctx.connections.refresh({ | 186 | yield* ctx.connections.refresh({ |
| 156 | owner: "org", | 187 | owner: "org", |
| 157 | integration: APPS_INTEGRATION, | 188 | integration: appSlug, |
| 158 | name: APPS_CONNECTION, | 189 | name: APPS_CONNECTION, |
| 159 | }); | 190 | }); |
| 160 | return; | 191 | return; |
| 161 | } | 192 | } |
| 162 | yield* ctx.connections.create({ | 193 | yield* ctx.connections.create({ |
| 163 | owner: "org", | 194 | owner: "org", |
| 164 | integration: APPS_INTEGRATION, | 195 | integration: appSlug, |
| 165 | name: APPS_CONNECTION, | 196 | name: APPS_CONNECTION, |
| 166 | template: APPS_NO_AUTH, | 197 | template: APPS_NO_AUTH, |
| 167 | values: {}, | 198 | values: {}, |
| 168 | }); | 199 | }); |
| 169 | }); | 200 | }); |
| 170 | 201 | ||
| 171 | const tokenItemId = (slug: string): ProviderItemId => | 202 | const guardAppIntegration = ( |
| 172 | ProviderItemId.make(`apps/source-tokens/${slug}`); | 203 | ctx: PluginCtx<AppsStore>, |
| 173 | 204 | record: AppSourceRecord, | |
| 174 | const gitHubConnectionToken = (ctx: PluginCtx<AppsStore>): Effect.Effect<string | null, unknown> => | 205 | ): Effect.Effect<void, unknown> => |
| 175 | Effect.gen(function* () { | 206 | Effect.gen(function* () { |
| 176 | const connections = yield* ctx.connections.list({ | 207 | const appSlug = IntegrationSlug.make(record.app); |
| 177 | integration: IntegrationSlug.make("github"), | 208 | const existing = yield* ctx.core.integrations.get(appSlug); |
| 178 | }); | 209 | if (existing && existing.kind !== "apps") { |
| 179 | const connection = connections.find((item) => item.owner === "user") ?? connections[0]; | 210 | return yield* new IntegrationAlreadyExistsError({ slug: appSlug }); |
| 180 | if (!connection) return null; | 211 | } |
| 181 | return yield* ctx.connections.resolveValue({ | 212 | const sources = yield* ctx.storage.listSources(); |
| 182 | owner: connection.owner, | 213 | const duplicate = sources.find( |
| 183 | integration: connection.integration, | 214 | (source) => source.slug !== record.slug && source.app === record.app, |
| 184 | name: connection.name, | 215 | ); |
| 185 | }); | 216 | if (duplicate) { |
| 217 | return yield* new AppSlugConflictError({ app: record.app }); | ||
| 218 | } | ||
| 186 | }); | 219 | }); |
| 187 | 220 | ||
| 221 | const appRegistrationErrorToDiagnostic = (error: unknown): SyncDiagnostic => { | ||
| 222 | if (Predicate.isTagged("IntegrationAlreadyExistsError")(error)) { | ||
| 223 | const slug = String((error as IntegrationAlreadyExistsError).slug); | ||
| 224 | return { | ||
| 225 | stage: "project", | ||
| 226 | message: `Integration slug collides with an existing integration: ${slug}`, | ||
| 227 | diagnostics: [{ path: slug, message: `Integration slug already exists: ${slug}` }], | ||
| 228 | }; | ||
| 229 | } | ||
| 230 | if (Predicate.isTagged("AppSlugConflictError")(error)) { | ||
| 231 | const app = (error as AppSlugConflictError).app; | ||
| 232 | const message = `app source already exists for slug: ${app}`; | ||
| 233 | return { | ||
| 234 | stage: "project", | ||
| 235 | message, | ||
| 236 | diagnostics: [{ path: app, message }], | ||
| 237 | }; | ||
| 238 | } | ||
| 239 | return { | ||
| 240 | stage: "project", | ||
| 241 | message: "App publish failed", | ||
| 242 | }; | ||
| 243 | }; | ||
| 244 | |||
| 245 | const tokenItemId = (slug: string): ProviderItemId => | ||
| 246 | ProviderItemId.make(`apps/source-tokens/${slug}`); | ||
| 247 | |||
| 248 | // A source authenticates only with its own explicitly provided token. No | ||
| 249 | // implicit credential sharing: a stored GitHub connection must never leak | ||
| 250 | // into git fetches the user did not tie to it. | ||
| 188 | const gitTokenFor = ( | 251 | const gitTokenFor = ( |
| 189 | ctx: PluginCtx<AppsStore>, | 252 | ctx: PluginCtx<AppsStore>, |
| 190 | config: Extract<AppSourceConfig, { readonly kind: "git" }>, | 253 | config: Extract<AppSourceConfig, { readonly kind: "git" }>, |
| @@ -196,9 +259,7 @@ const gitTokenFor = ( · 5 unmodified lines | |||
| 196 | ProviderItemId.make(config.tokenItemId), | 259 | ProviderItemId.make(config.tokenItemId), |
| 197 | ); | 260 | ); |
| 198 | } | 261 | } |
| 199 | const url = yield* parseGitSourceUrl(config.url).pipe(Effect.result); | 262 | return null; |
| 200 | if (Result.isFailure(url) || url.success.hostname.toLowerCase() !== "github.com") return null; | ||
| 201 | return yield* gitHubConnectionToken(ctx); | ||
| 202 | }); | 263 | }); |
| 203 | 264 | ||
| 204 | const activeToolNamesFor = ( | 265 | const activeToolNamesFor = ( |
| @@ -207,13 +268,7 @@ const activeToolNamesFor = ( · 2 unmodified lines | |||
| 207 | ): Effect.Effect<readonly string[], unknown> => | 268 | ): Effect.Effect<readonly string[], unknown> => |
| 208 | ctx.storage | 269 | ctx.storage |
| 209 | .listActiveTools() | 270 | .listActiveTools() |
| 210 | .pipe( | 271 | .pipe(Effect.map((tools) => tools.filter((tool) => tool.app === app).map((tool) => tool.name))); |
| 211 | Effect.map((tools) => | ||
| 212 | tools | ||
| 213 | .filter((tool) => tool.name.startsWith(`${app}__`) || tool.name === app) | ||
| 214 | .map((tool) => tool.name), | ||
| 215 | ), | ||
| 216 | ); | ||
| 217 | 272 | ||
| 218 | const makeAppsExtension = ( | 273 | const makeAppsExtension = ( |
| 219 | ctx: PluginCtx<AppsStore>, | 274 | ctx: PluginCtx<AppsStore>, |
| @@ -232,6 +287,15 @@ const makeAppsExtension = ( · 12 unmodified lines | |||
| 232 | publish({ store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, input), | 287 | publish({ store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, input), |
| 233 | listSources: () => ctx.storage.listSources(), | 288 | listSources: () => ctx.storage.listSources(), |
| 234 | getSource: (slug: string) => ctx.storage.getSource(slug), | 289 | getSource: (slug: string) => ctx.storage.getSource(slug), |
| 290 | listDirs: (input: { readonly path?: string; readonly includeHidden?: boolean }) => | ||
| 291 | Effect.gen(function* () { | ||
| 292 | if (!sourceKinds.includes("local-directory")) { | ||
| 293 | return yield* new AppPluginError({ | ||
| 294 | message: "app source kind is not enabled: local-directory", | ||
| 295 | }); | ||
| 296 | } | ||
| 297 | return yield* listLocalDirectoryDirs(input); | ||
| 298 | }), | ||
| 235 | createSource: (input: CreateAppSourceInput) => | 299 | createSource: (input: CreateAppSourceInput) => |
| 236 | Effect.gen(function* () { | 300 | Effect.gen(function* () { |
| 237 | const config = sourceConfig(input); | 301 | const config = sourceConfig(input); |
| @@ -276,11 +340,18 @@ const makeAppsExtension = ( · 38 unmodified lines | |||
| 276 | updatedAt: now(), | 340 | updatedAt: now(), |
| 277 | }; | 341 | }; |
| 278 | yield* ctx.storage.putSource(record, "org"); | 342 | yield* ctx.storage.putSource(record, "org"); |
| 343 | yield* ensureAppIntegration(ctx, record); | ||
| 279 | return record; | 344 | return record; |
| 280 | }), | 345 | }), |
| 281 | deleteSource: (slug: string) => | 346 | deleteSource: (slug: string) => |
| 282 | Effect.gen(function* () { | 347 | Effect.gen(function* () { |
| 283 | const record = yield* ctx.storage.getSource(slug); | 348 | const record = yield* ctx.storage.getSource(slug); |
| 349 | if (record) { | ||
| 350 | yield* ctx.storage.removePublished(record.app, "org"); | ||
| 351 | yield* ctx.core.integrations | ||
| 352 | .remove(IntegrationSlug.make(record.app)) | ||
| 353 | .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void)); | ||
| 354 | } | ||
| 284 | if ( | 355 | if ( |
| 285 | record?.config.kind === "git" && | 356 | record?.config.kind === "git" && |
| 286 | record.config.tokenProvider && | 357 | record.config.tokenProvider && |
| @@ -323,6 +394,17 @@ const makeAppsExtension = ( · 36 unmodified lines | |||
| 323 | return { status: "failed", tools: [], errors: [diagnostic] }; | 394 | return { status: "failed", tools: [], errors: [diagnostic] }; |
| 324 | } | 395 | } |
| 325 | if (record.sourceRef === checked.success.sourceRef) { | 396 | if (record.sourceRef === checked.success.sourceRef) { |
| 397 | const guarded = yield* guardAppIntegration(ctx, record).pipe(Effect.result); | ||
| 398 | if (Result.isFailure(guarded)) { | ||
| 399 | const diagnostic = appRegistrationErrorToDiagnostic(guarded.failure); | ||
| 400 | const failed: AppSourceRecord = { | ||
| 401 | ...record, | ||
| 402 | status: { type: "failed", at: now(), errors: [diagnostic] }, | ||
| 403 | updatedAt: now(), | ||
| 404 | }; | ||
| 405 | yield* ctx.storage.putSource(failed, "org"); | ||
| 406 | return { status: "failed", tools: [], errors: [diagnostic] }; | ||
| 407 | } | ||
| 326 | const tools = yield* activeToolNamesFor(ctx, record.app); | 408 | const tools = yield* activeToolNamesFor(ctx, record.app); |
| 327 | const updated: AppSourceRecord = { | 409 | const updated: AppSourceRecord = { |
| 328 | ...record, | 410 | ...record, |
| @@ -330,7 +412,7 @@ const makeAppsExtension = ( · 1 unmodified line | |||
| 330 | updatedAt: now(), | 412 | updatedAt: now(), |
| 331 | }; | 413 | }; |
| 332 | yield* ctx.storage.putSource(updated, "org"); | 414 | yield* ctx.storage.putSource(updated, "org"); |
| 333 | yield* ensureAppsCatalogConnection(ctx); | 415 | yield* ensureAppConnection(ctx, updated); |
| 334 | return { status: "up-to-date", sourceRef: checked.success.sourceRef, tools }; | 416 | return { status: "up-to-date", sourceRef: checked.success.sourceRef, tools }; |
| 335 | } | 417 | } |
| 336 | } | 418 | } |
| @@ -361,16 +443,49 @@ const makeAppsExtension = ( · 24 unmodified lines | |||
| 361 | } | 443 | } |
| 362 | const snapshot: AppSourceSnapshot = fetched.success; | 444 | const snapshot: AppSourceSnapshot = fetched.success; |
| 363 | if (record.sourceRef === snapshot.sourceRef) { | 445 | if (record.sourceRef === snapshot.sourceRef) { |
| 446 | const guarded = yield* guardAppIntegration(ctx, record).pipe(Effect.result); | ||
| 447 | if (Result.isFailure(guarded)) { | ||
| 448 | const diagnostic = appRegistrationErrorToDiagnostic(guarded.failure); | ||
| 449 | const failed: AppSourceRecord = { | ||
| 450 | ...record, | ||
| 451 | description: snapshot.description, | ||
| 452 | status: { type: "failed", at: now(), errors: [diagnostic] }, | ||
| 453 | updatedAt: now(), | ||
| 454 | }; | ||
| 455 | yield* ctx.storage.putSource(failed, "org"); | ||
| 456 | return { status: "failed", tools: [], errors: [diagnostic] }; | ||
| 457 | } | ||
| 364 | const tools = yield* activeToolNamesFor(ctx, record.app); | 458 | const tools = yield* activeToolNamesFor(ctx, record.app); |
| 365 | const updated: AppSourceRecord = { | 459 | const updated: AppSourceRecord = { |
| 366 | ...record, | 460 | ...record, |
| 461 | description: snapshot.description, | ||
| 367 | status: { type: "up-to-date", at: now(), tools }, | 462 | status: { type: "up-to-date", at: now(), tools }, |
| 368 | updatedAt: now(), | 463 | updatedAt: now(), |
| 369 | }; | 464 | }; |
| 370 | yield* ctx.storage.putSource(updated, "org"); | 465 | yield* ctx.storage.putSource(updated, "org"); |
| 371 | yield* ensureAppsCatalogConnection(ctx); | 466 | yield* ensureAppConnection(ctx, updated, snapshot.description); |
| 372 | return { status: "up-to-date", sourceRef: snapshot.sourceRef, tools }; | 467 | return { status: "up-to-date", sourceRef: snapshot.sourceRef, tools }; |
| 373 | } | 468 | } |
| 469 | const guarded = yield* guardAppIntegration(ctx, record).pipe(Effect.result); | ||
| 470 | if (Result.isFailure(guarded)) { | ||
| 471 | const diagnostic = appRegistrationErrorToDiagnostic(guarded.failure); | ||
| 472 | yield* ctx.storage.putSource( | ||
| 473 | { | ||
| 474 | ...record, | ||
| 475 | sourceRef: snapshot.sourceRef, | ||
| 476 | description: snapshot.description, | ||
| 477 | status: { type: "failed", at: now(), errors: [diagnostic] }, | ||
| 478 | updatedAt: now(), | ||
| 479 | }, | ||
| 480 | "org", | ||
| 481 | ); | ||
| 482 | return { | ||
| 483 | status: "failed", | ||
| 484 | sourceRef: snapshot.sourceRef, | ||
| 485 | tools: [], | ||
| 486 | errors: [diagnostic], | ||
| 487 | }; | ||
| 488 | } | ||
| 374 | const published = yield* publish( | 489 | const published = yield* publish( |
| 375 | { store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, | 490 | { store: ctx.storage, executor: activeExecutor, bundler: activeBundler }, |
| 376 | { | 491 | { |
| @@ -412,7 +527,7 @@ const makeAppsExtension = ( · 35 unmodified lines | |||
| 412 | }, | 527 | }, |
| 413 | "org", | 528 | "org", |
| 414 | ); | 529 | ); |
| 415 | yield* ensureAppsCatalogConnection(ctx); | 530 | yield* ensureAppConnection(ctx, record, snapshot.description); |
| 416 | return { | 531 | return { |
| 417 | status: published.success.noop ? "up-to-date" : "published", | 532 | status: published.success.noop ? "up-to-date" : "published", |
| 418 | sourceRef: snapshot.sourceRef, | 533 | sourceRef: snapshot.sourceRef, |
| @@ -435,6 +550,7 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 16 unmodified lines | |||
| 435 | definePlugin(() => ({ | 550 | definePlugin(() => ({ |
| 436 | id: "apps", | 551 | id: "apps", |
| 437 | packageName: "@executor-js/plugin-apps", | 552 | packageName: "@executor-js/plugin-apps", |
| 553 | clientConfig: { sourceKinds: options?.sourceKinds ?? ["git", "local-directory"] }, | ||
| 438 | pluginStorage: { | 554 | pluginStorage: { |
| 439 | [descriptorCollection.name]: descriptorCollection, | 555 | [descriptorCollection.name]: descriptorCollection, |
| 440 | [toolCollection.name]: toolCollection, | 556 | [toolCollection.name]: toolCollection, |
| @@ -442,49 +558,55 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 1 unmodified line | |||
| 442 | }, | 558 | }, |
| 443 | storage: ({ blobs, pluginStorage }) => makeAppsStore({ blobs, pluginStorage }), | 559 | storage: ({ blobs, pluginStorage }) => makeAppsStore({ blobs, pluginStorage }), |
| 444 | extension: (ctx: PluginCtx<AppsStore>) => makeAppsExtension(ctx, options), | 560 | extension: (ctx: PluginCtx<AppsStore>) => makeAppsExtension(ctx, options), |
| 445 | staticSources: () => [ | 561 | resolveTools: ({ storage, connection }) => |
| 446 | { | ||
| 447 | id: "apps", | ||
| 448 | kind: "apps", | ||
| 449 | name: "Apps", | ||
| 450 | canRemove: false, | ||
| 451 | canRefresh: false, | ||
| 452 | tools: [], | ||
| 453 | }, | ||
| 454 | ], | ||
| 455 | resolveTools: ({ storage }) => | ||
| 456 | storage.listActiveTools().pipe( | 562 | storage.listActiveTools().pipe( |
| 457 | Effect.map((tools) => ({ | 563 | Effect.map((tools) => ({ |
| 458 | tools: tools.map( | 564 | tools: tools |
| 459 | (tool): ToolDef => ({ | 565 | .filter((tool) => tool.app === String(connection.integration)) |
| 460 | name: ToolName.make(tool.name), | 566 | .map( |
| 461 | description: tool.description, | 567 | (tool): ToolDef => ({ |
| 462 | inputSchema: tool.inputSchema, | 568 | name: ToolName.make(tool.name), |
| 463 | outputSchema: tool.outputSchema, | 569 | description: tool.description, |
| 464 | annotations: { | 570 | inputSchema: tool.inputSchema, |
| 465 | ...(tool.annotations?.requiresApproval !== undefined | 571 | outputSchema: tool.outputSchema, |
| 466 | ? { requiresApproval: tool.annotations.requiresApproval } | 572 | annotations: { |
| 467 | : {}), | 573 | ...(tool.annotations?.requiresApproval !== undefined |
| 468 | ...(tool.annotations?.readOnly === true ? { requiresApproval: false } : {}), | 574 | ? { requiresApproval: tool.annotations.requiresApproval } |
| 469 | }, | 575 | : {}), |
| 470 | }), | 576 | ...(tool.annotations?.readOnly === true ? { requiresApproval: false } : {}), |
| 471 | ), | 577 | }, |
| 578 | }), | ||
| 579 | ), | ||
| 472 | })), | 580 | })), |
| 473 | ), | 581 | ), |
| 474 | projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) => | 582 | projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) => |
| 475 | projectAppsToolSchema(ctx, String(toolRow.name), inputSchema, outputSchema), | 583 | projectAppsToolSchema( |
| 584 | ctx, | ||
| 585 | String(toolRow.integration), | ||
| 586 | String(toolRow.name), | ||
| 587 | inputSchema, | ||
| 588 | outputSchema, | ||
| 589 | ), | ||
| 476 | validateToolArgs: ({ ctx, toolRow, args }) => | 590 | validateToolArgs: ({ ctx, toolRow, args }) => |
| 477 | Effect.gen(function* () { | 591 | Effect.gen(function* () { |
| 478 | const tool = yield* ctx.storage.getTool(String(toolRow.name)); | 592 | const tool = yield* ctx.storage.getToolForApp( |
| 593 | String(toolRow.integration), | ||
| 594 | String(toolRow.name), | ||
| 595 | ); | ||
| 479 | if (!tool) return; | 596 | if (!tool) return; |
| 480 | const resolver = makePluginCtxAppsResolver({ ctx }); | 597 | const resolver = makePluginCtxAppsResolver({ ctx }); |
| 481 | yield* resolveIntegrationBindings(tool.integrations, args, resolver); | 598 | yield* resolveIntegrationBindings(tool.integrations, args, resolver); |
| 482 | }), | 599 | }), |
| 483 | invokeTool: ({ ctx, toolRow, args, invokeOptions }) => | 600 | invokeTool: ({ ctx, toolRow, args, invokeOptions }) => |
| 484 | Effect.gen(function* () { | 601 | Effect.gen(function* () { |
| 485 | const tool = yield* ctx.storage.getTool(String(toolRow.name)); | 602 | const tool = yield* ctx.storage.getToolForApp( |
| 603 | String(toolRow.integration), | ||
| 604 | String(toolRow.name), | ||
| 605 | ); | ||
| 486 | if (!tool) { | 606 | if (!tool) { |
| 487 | return yield* new AppPluginError({ message: `app tool not found: ${toolRow.name}` }); | 607 | return yield* new AppPluginError({ |
| 608 | message: `app tool not found: ${toolRow.integration}.${toolRow.name}`, | ||
| 609 | }); | ||
| 488 | } | 610 | } |
| 489 | const bundle = yield* ctx.storage.getBlob(tool.bundleKey); | 611 | const bundle = yield* ctx.storage.getBlob(tool.bundleKey); |
| 490 | if (!bundle) { | 612 | if (!bundle) { |
| @@ -525,12 +647,13 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => · 34 unmodified lines | |||
| 525 | 647 | ||
| 526 | export const projectAppsToolSchema = ( | 648 | export const projectAppsToolSchema = ( |
| 527 | ctx: PluginCtx<AppsStore>, | 649 | ctx: PluginCtx<AppsStore>, |
| 650 | app: string, | ||
| 528 | toolName: string, | 651 | toolName: string, |
| 529 | inputSchema: unknown, | 652 | inputSchema: unknown, |
| 530 | outputSchema: unknown, | 653 | outputSchema: unknown, |
| 531 | ): Effect.Effect<ProjectedToolSchema, unknown> => | 654 | ): Effect.Effect<ProjectedToolSchema, unknown> => |
| 532 | Effect.gen(function* () { | 655 | Effect.gen(function* () { |
| 533 | const tool = yield* ctx.storage.getTool(toolName); | 656 | const tool = yield* ctx.storage.getToolForApp(app, toolName); |
| 534 | if (!tool || !inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) { | 657 | if (!tool || !inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) { |
| 535 | return { inputSchema, outputSchema }; | 658 | return { inputSchema, outputSchema }; |
| 536 | } | 659 | } |
| @@ -546,12 +669,34 @@ export const projectAppsToolSchema = ( · 9 unmodified lines | |||
| 546 | const enumValues = connections.map((connection) => String(connection.address)); | 669 | const enumValues = connections.map((connection) => String(connection.address)); |
| 547 | properties[field] = | 670 | properties[field] = |
| 548 | decl.mode === "many" | 671 | decl.mode === "many" |
| 549 | ? { type: "array", items: { type: "string", enum: enumValues } } | 672 | ? { |
| 550 | : { type: "string", enum: enumValues }; | 673 | type: "array", |
| 674 | items: { type: "string", enum: enumValues }, | ||
| 675 | default: enumValues, | ||
| 676 | } | ||
| 677 | : { | ||
| 678 | type: "string", | ||
| 679 | enum: enumValues, | ||
| 680 | ...(enumValues[0] ? { default: enumValues[0] } : {}), | ||
| 681 | }; | ||
| 551 | } | 682 | } |
| 552 | return { inputSchema: { ...schema, properties }, outputSchema }; | 683 | const projectedFields = new Set(Object.keys(tool.integrations)); |
| 684 | const required = Array.isArray(schema.required) | ||
| 685 | ? schema.required.filter((field) => !projectedFields.has(String(field))) | ||
| 686 | : undefined; | ||
| 687 | // Rebuild without the `required` key rather than setting it to undefined: | ||
| 688 | // an explicit undefined is not a JSON value and fails response encoding. | ||
| 689 | const { required: _dropped, ...rest } = schema; | ||
| 690 | return { | ||
| 691 | inputSchema: { | ||
| 692 | ...rest, | ||
| 693 | properties, | ||
| 694 | ...(required && required.length > 0 ? { required } : {}), | ||
| 695 | }, | ||
| 696 | outputSchema, | ||
| 697 | }; | ||
| 553 | }); | 698 | }); |
| 554 | 699 | ||
| 555 | export const appsPlugin = makeAppsPlugin(); | 700 | export const appsPlugin = makeAppsPlugin(); |
| 556 | 701 | ||
| 557 | export { APPS_INTEGRATION, APPS_CONNECTION }; | 702 | export { APPS_CONNECTION }; |
| @@ -5,12 +5,13 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 4 unmodified lines | |||
| 5 | import { addGroup, capture } from "@executor-js/api"; | 5 | import { addGroup, capture } from "@executor-js/api"; |
| 6 | import { InternalError } from "@executor-js/sdk/shared"; | 6 | import { InternalError } from "@executor-js/sdk/shared"; |
| 7 | import type { AppsExtension } from "./apps-plugin"; | 7 | import type { AppsExtension } from "./apps-plugin"; |
| 8 | import { AppsGroup } from "./routes"; | 8 | import { AppsBaseGroup, AppsGroup } from "./routes"; |
| 9 | 9 | ||
| 10 | export class AppsExtensionService extends Context.Service<AppsExtensionService, AppsExtension>()( | 10 | export class AppsExtensionService extends Context.Service<AppsExtensionService, AppsExtension>()( |
| 11 | "AppsExtensionService", | 11 | "AppsExtensionService", |
| 12 | ) {} | 12 | ) {} |
| 13 | 13 | ||
| 14 | const ExecutorApiWithAppsBase = addGroup(AppsBaseGroup); | ||
| 14 | const ExecutorApiWithApps = addGroup(AppsGroup); | 15 | const ExecutorApiWithApps = addGroup(AppsGroup); |
| 15 | 16 | ||
| 16 | const captureUnexpected = <A, E, R>( | 17 | const captureUnexpected = <A, E, R>( |
| @@ -18,6 +19,53 @@ const captureUnexpected = <A, E, R>( · 1 unmodified line | |||
| 18 | ): Effect.Effect<A, InternalError, R> => | 19 | ): Effect.Effect<A, InternalError, R> => |
| 19 | capture(effect.pipe(Effect.catch((cause: unknown) => Effect.die(cause)))); | 20 | capture(effect.pipe(Effect.catch((cause: unknown) => Effect.die(cause)))); |
| 20 | 21 | ||
| 22 | export const AppsBaseHandlers = HttpApiBuilder.group(ExecutorApiWithAppsBase, "apps", (handlers) => | ||
| 23 | handlers | ||
| 24 | .handle("listSources", () => | ||
| 25 | captureUnexpected( | ||
| 26 | Effect.gen(function* () { | ||
| 27 | const ext = yield* AppsExtensionService; | ||
| 28 | const sources = yield* ext.listSources(); | ||
| 29 | return { sources }; | ||
| 30 | }), | ||
| 31 | ), | ||
| 32 | ) | ||
| 33 | .handle("createSource", ({ payload }) => | ||
| 34 | captureUnexpected( | ||
| 35 | Effect.gen(function* () { | ||
| 36 | const ext = yield* AppsExtensionService; | ||
| 37 | const source = yield* ext.createSource(payload); | ||
| 38 | return { source }; | ||
| 39 | }), | ||
| 40 | ), | ||
| 41 | ) | ||
| 42 | .handle("getSource", ({ params }) => | ||
| 43 | captureUnexpected( | ||
| 44 | Effect.gen(function* () { | ||
| 45 | const ext = yield* AppsExtensionService; | ||
| 46 | const source = yield* ext.getSource(params.slug); | ||
| 47 | return { source }; | ||
| 48 | }), | ||
| 49 | ), | ||
| 50 | ) | ||
| 51 | .handle("deleteSource", ({ params }) => | ||
| 52 | captureUnexpected( | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const ext = yield* AppsExtensionService; | ||
| 55 | return yield* ext.deleteSource(params.slug); | ||
| 56 | }), | ||
| 57 | ), | ||
| 58 | ) | ||
| 59 | .handle("syncSource", ({ params }) => | ||
| 60 | captureUnexpected( | ||
| 61 | Effect.gen(function* () { | ||
| 62 | const ext = yield* AppsExtensionService; | ||
| 63 | return yield* ext.syncSource(params.slug); | ||
| 64 | }), | ||
| 65 | ), | ||
| 66 | ), | ||
| 67 | ); | ||
| 68 | |||
| 21 | export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) => | 69 | export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (handlers) => |
| 22 | handlers | 70 | handlers |
| 23 | .handle("listSources", () => | 71 | .handle("listSources", () => |
| @@ -62,5 +110,20 @@ export const AppsHandlers = HttpApiBuilder.group(ExecutorApiWithApps, "apps", (h · 38 unmodified lines | |||
| 62 | return yield* ext.syncSource(params.slug); | 110 | return yield* ext.syncSource(params.slug); |
| 63 | }), | 111 | }), |
| 64 | ), | 112 | ), |
| 113 | ) | ||
| 114 | .handle("listDirs", ({ query }) => | ||
| 115 | captureUnexpected( | ||
| 116 | Effect.gen(function* () { | ||
| 117 | const ext = yield* AppsExtensionService; | ||
| 118 | return yield* ext.listDirs({ | ||
| 119 | ...(query.path ? { path: query.path } : {}), | ||
| 120 | includeHidden: query.includeHidden === "true" || query.includeHidden === "1", | ||
| 121 | }); | ||
| 122 | }), | ||
| 123 | ), | ||
| 65 | ), | 124 | ), |
| 66 | ); | 125 | ); |
| 126 | |||
| 127 | export const appsHandlersForSourceKinds = ( | ||
| 128 | sourceKinds: readonly ("git" | "local-directory")[] | undefined, | ||
| 129 | ) => ((sourceKinds?.includes("local-directory") ?? true) ? AppsHandlers : AppsBaseHandlers); | ||
| @@ -67,9 +67,31 @@ const SyncSourceResponse = Schema.Struct({ · 66 unmodified lines | |||
| 67 | errors: Schema.optional(Schema.Array(SyncDiagnostic)), | 67 | errors: Schema.optional(Schema.Array(SyncDiagnostic)), |
| 68 | }); | 68 | }); |
| 69 | 69 | ||
| 70 | const ListDirsQuery = Schema.Struct({ | ||
| 71 | path: Schema.optional(Schema.String), | ||
| 72 | includeHidden: Schema.optional(Schema.String), | ||
| 73 | }); | ||
| 74 | const ListDirsResponse = Schema.Struct({ | ||
| 75 | path: Schema.String, | ||
| 76 | parent: Schema.NullOr(Schema.String), | ||
| 77 | dirs: Schema.Array( | ||
| 78 | Schema.Struct({ | ||
| 79 | name: Schema.String, | ||
| 80 | path: Schema.String, | ||
| 81 | isSymlink: Schema.Boolean, | ||
| 82 | hasTools: Schema.Boolean, | ||
| 83 | }), | ||
| 84 | ), | ||
| 85 | source: Schema.Struct({ | ||
| 86 | toolFiles: Schema.Array(Schema.String), | ||
| 87 | skipped: Schema.Array(Schema.String), | ||
| 88 | hasPackageJson: Schema.Boolean, | ||
| 89 | }), | ||
| 90 | }); | ||
| 91 | |||
| 70 | const DomainErrors = [InternalError] as const; | 92 | const DomainErrors = [InternalError] as const; |
| 71 | 93 | ||
| 72 | export const AppsGroup = HttpApiGroup.make("apps") | 94 | export const AppsBaseGroup = HttpApiGroup.make("apps") |
| 73 | .add( | 95 | .add( |
| 74 | HttpApiEndpoint.get("listSources", "/apps/sources", { | 96 | HttpApiEndpoint.get("listSources", "/apps/sources", { |
| 75 | success: SourcesResponse, | 97 | success: SourcesResponse, |
| @@ -104,3 +126,18 @@ export const AppsGroup = HttpApiGroup.make("apps") · 28 unmodified lines | |||
| 104 | error: DomainErrors, | 126 | error: DomainErrors, |
| 105 | }), | 127 | }), |
| 106 | ); | 128 | ); |
| 129 | |||
| 130 | export const AppsGroup = AppsBaseGroup.add( | ||
| 131 | // Local-directory browsing names directories on the host running Executor. | ||
| 132 | // It is only safe for the local single-user host, and the plugin sourceKinds | ||
| 133 | // gate keeps shared selfhost and cloud deployments from registering it. | ||
| 134 | HttpApiEndpoint.get("listDirs", "/apps/fs/dirs", { | ||
| 135 | query: ListDirsQuery, | ||
| 136 | success: ListDirsResponse, | ||
| 137 | error: DomainErrors, | ||
| 138 | }), | ||
| 139 | ); | ||
| 140 | |||
| 141 | export const appsGroupForSourceKinds = ( | ||
| 142 | sourceKinds: readonly ("git" | "local-directory")[] | undefined, | ||
| 143 | ) => ((sourceKinds?.includes("local-directory") ?? true) ? AppsGroup : AppsBaseGroup); | ||
| @@ -177,6 +177,27 @@ describe("apps store", () => { · 176 unmodified lines | |||
| 177 | yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1"); | 177 | yield* store.putPublished(descriptor([tool("first")]), "descriptor-2", "org", "sha-1"); |
| 178 | const active = yield* store.listActiveTools(); | 178 | const active = yield* store.listActiveTools(); |
| 179 | expect(active.map((item) => item.name)).toEqual(["first"]); | 179 | expect(active.map((item) => item.name)).toEqual(["first"]); |
| 180 | expect(active.map((item) => item.app)).toEqual(["crm"]); | ||
| 181 | }), | ||
| 182 | ); | ||
| 183 | |||
| 184 | it.effect("removes a published app catalog", () => | ||
| 185 | Effect.gen(function* () { | ||
| 186 | const store = makeAppsStore({ | ||
| 187 | blobs: pluginBlobStore(makeInMemoryBlobStore(), { org: "tenant", user: null }, "apps"), | ||
| 188 | pluginStorage: makeMemoryPluginStorage(), | ||
| 189 | }); | ||
| 190 | yield* store.putPublished( | ||
| 191 | descriptor([tool("first"), tool("second")]), | ||
| 192 | "descriptor", | ||
| 193 | "org", | ||
| 194 | null, | ||
| 195 | ); | ||
| 196 | yield* store.removePublished("crm", "org"); | ||
| 197 | const active = yield* store.listActiveTools(); | ||
| 198 | const descriptorRecord = yield* store.getDescriptorRecord("crm"); | ||
| 199 | expect(active).toEqual([]); | ||
| 200 | expect(descriptorRecord).toBeNull(); | ||
| 180 | }), | 201 | }), |
| 181 | ); | 202 | ); |
| 182 | 203 | ||
| @@ -102,8 +102,9 @@ export interface AppsStore { · 101 unmodified lines | |||
| 102 | owner: Owner, | 102 | owner: Owner, |
| 103 | expectedSourceRef: string | null, | 103 | expectedSourceRef: string | null, |
| 104 | ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>; | 104 | ) => Effect.Effect<void, StorageFailure | AppPublishConflictError>; |
| 105 | readonly removePublished: (app: string, owner: Owner) => Effect.Effect<void, StorageFailure>; | ||
| 105 | readonly listActiveTools: () => Effect.Effect< | 106 | readonly listActiveTools: () => Effect.Effect< |
| 106 | readonly AppDescriptor["tools"][number][], | 107 | readonly (AppDescriptor["tools"][number] & { readonly app: string })[], |
| 107 | StorageFailure | 108 | StorageFailure |
| 108 | >; | 109 | >; |
| 109 | readonly getTool: (name: string) => Effect.Effect< | 110 | readonly getTool: (name: string) => Effect.Effect< |
| @@ -119,6 +120,22 @@ export interface AppsStore { · 9 unmodified lines | |||
| 119 | } | null, | 120 | } | null, |
| 120 | StorageFailure | 121 | StorageFailure |
| 121 | >; | 122 | >; |
| 123 | readonly getToolForApp: ( | ||
| 124 | app: string, | ||
| 125 | name: string, | ||
| 126 | ) => Effect.Effect< | ||
| 127 | { | ||
| 128 | readonly app: string; | ||
| 129 | readonly name: string; | ||
| 130 | readonly bundleKey: string; | ||
| 131 | readonly description: string; | ||
| 132 | readonly inputSchema?: unknown; | ||
| 133 | readonly outputSchema?: unknown; | ||
| 134 | readonly integrations: AppDescriptor["tools"][number]["integrations"]; | ||
| 135 | readonly annotations?: AppDescriptor["tools"][number]["annotations"]; | ||
| 136 | } | null, | ||
| 137 | StorageFailure | ||
| 138 | >; | ||
| 122 | readonly putSource: ( | 139 | readonly putSource: ( |
| 123 | record: AppSourceRecord, | 140 | record: AppSourceRecord, |
| 124 | owner: Owner, | 141 | owner: Owner, |
| @@ -215,10 +232,27 @@ export const makeAppsStore = (input: { · 90 unmodified lines | |||
| 215 | }, | 232 | }, |
| 216 | }); | 233 | }); |
| 217 | }), | 234 | }), |
| 235 | removePublished: (app, owner) => | ||
| 236 | Effect.gen(function* () { | ||
| 237 | const existing = yield* tools.query({ where: { app } }); | ||
| 238 | const now = Date.now(); | ||
| 239 | const entries: PutManyEntry[] = existing | ||
| 240 | .filter((entry) => !entry.data.tombstoned) | ||
| 241 | .map((entry) => ({ | ||
| 242 | collection: toolCollection.name, | ||
| 243 | key: entry.key, | ||
| 244 | data: { ...entry.data, tombstoned: true, updatedAt: now }, | ||
| 245 | })); | ||
| 246 | if (entries.length > 0) { | ||
| 247 | yield* input.pluginStorage.putMany({ owner, entries }); | ||
| 248 | } | ||
| 249 | yield* descriptors.remove({ owner, key: app }); | ||
| 250 | }), | ||
| 218 | listActiveTools: () => | 251 | listActiveTools: () => |
| 219 | tools.query({ where: { tombstoned: false } }).pipe( | 252 | tools.query({ where: { tombstoned: false } }).pipe( |
| 220 | Effect.map((entries) => | 253 | Effect.map((entries) => |
| 221 | entries.map((entry) => ({ | 254 | entries.map((entry) => ({ |
| 255 | app: entry.data.app, | ||
| 222 | name: entry.data.name, | 256 | name: entry.data.name, |
| 223 | sourcePath: "", | 257 | sourcePath: "", |
| 224 | source: { path: "", sourceHash: "" }, | 258 | source: { path: "", sourceHash: "" }, |
| @@ -249,6 +283,24 @@ export const makeAppsStore = (input: { · 24 unmodified lines | |||
| 249 | : null; | 283 | : null; |
| 250 | }), | 284 | }), |
| 251 | ), | 285 | ), |
| 286 | getToolForApp: (app, name) => | ||
| 287 | tools.query({ where: { app, name, tombstoned: false }, limit: 1 }).pipe( | ||
| 288 | Effect.map((entries) => { | ||
| 289 | const entry = entries[0]; | ||
| 290 | return entry | ||
| 291 | ? { | ||
| 292 | app: entry.data.app, | ||
| 293 | name: entry.data.name, | ||
| 294 | bundleKey: entry.data.bundleKey, | ||
| 295 | description: entry.data.description, | ||
| 296 | inputSchema: entry.data.inputSchema, | ||
| 297 | outputSchema: entry.data.outputSchema, | ||
| 298 | integrations: entry.data.integrations, | ||
| 299 | annotations: entry.data.annotations, | ||
| 300 | } | ||
| 301 | : null; | ||
| 302 | }), | ||
| 303 | ), | ||
| 252 | putSource: (record, owner) => | 304 | putSource: (record, owner) => |
| 253 | sources.put({ owner, key: record.slug, data: record }).pipe(Effect.asVoid), | 305 | sources.put({ owner, key: record.slug, data: record }).pipe(Effect.asVoid), |
| 254 | listSources: () => | 306 | listSources: () => |
| @@ -0,0 +1,459 @@ | |||
| 1 | import { useEffect, 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 { Checkbox } from "@executor-js/react/components/checkbox"; | ||
| 13 | import { Label } from "@executor-js/react/components/label"; | ||
| 14 | import { | ||
| 15 | Dialog, | ||
| 16 | DialogContent, | ||
| 17 | DialogDescription, | ||
| 18 | DialogFooter, | ||
| 19 | DialogHeader, | ||
| 20 | DialogTitle, | ||
| 21 | } from "@executor-js/react/components/dialog"; | ||
| 22 | import { FormErrorAlert, useSlugAlreadyExists } from "@executor-js/react/lib/integration-add"; | ||
| 23 | |||
| 24 | import { | ||
| 25 | createCustomToolSourceEffect, | ||
| 26 | formatSyncErrors, | ||
| 27 | listCustomToolDirectoriesEffect, | ||
| 28 | parseGitSourceUrl, | ||
| 29 | slugifyCustomToolsAppName, | ||
| 30 | suggestCustomToolsAppName, | ||
| 31 | syncCustomToolSourceEffect, | ||
| 32 | validateCustomToolsAppSlug, | ||
| 33 | validateGitSourceUrl, | ||
| 34 | type CustomToolsDirectoryListing, | ||
| 35 | type AppSourceKind, | ||
| 36 | } from "./custom-tools-client"; | ||
| 37 | import { directoryBrowserRows, directorySourceVerdict } from "./source-panel-model"; | ||
| 38 | |||
| 39 | export default function AddCustomToolsSource(props: { | ||
| 40 | readonly onComplete: (slug?: string) => void; | ||
| 41 | readonly onCancel: () => void; | ||
| 42 | readonly initialUrl?: string; | ||
| 43 | readonly initialNamespace?: string; | ||
| 44 | readonly sourceKinds?: readonly AppSourceKind[]; | ||
| 45 | }) { | ||
| 46 | const sourceKinds = props.sourceKinds ?? ["git"]; | ||
| 47 | const allowLocalDirectory = sourceKinds.includes("local-directory"); | ||
| 48 | const initialKind = | ||
| 49 | props.initialUrl && props.initialUrl.startsWith("/") && allowLocalDirectory | ||
| 50 | ? "local-directory" | ||
| 51 | : "git"; | ||
| 52 | const [kind, setKind] = useState<AppSourceKind>(initialKind); | ||
| 53 | const [url, setUrl] = useState(props.initialUrl ?? ""); | ||
| 54 | const [path, setPath] = useState( | ||
| 55 | initialKind === "local-directory" ? (props.initialUrl ?? "") : "", | ||
| 56 | ); | ||
| 57 | const [ref, setRef] = useState(""); | ||
| 58 | const [name, setName] = useState( | ||
| 59 | props.initialNamespace ? slugifyCustomToolsAppName(props.initialNamespace) : "", | ||
| 60 | ); | ||
| 61 | const [nameTouched, setNameTouched] = useState(props.initialNamespace !== undefined); | ||
| 62 | const [token, setToken] = useState(""); | ||
| 63 | const [fieldError, setFieldError] = useState<string | null>(null); | ||
| 64 | const [nameError, setNameError] = useState<string | null>(null); | ||
| 65 | const [syncing, setSyncing] = useState(false); | ||
| 66 | const [syncError, setSyncError] = useState<string | null>(null); | ||
| 67 | const [browseOpen, setBrowseOpen] = useState(false); | ||
| 68 | const [pathListing, setPathListing] = useState<CustomToolsDirectoryListing | null>(null); | ||
| 69 | |||
| 70 | const sourceValue = kind === "git" ? url : path; | ||
| 71 | const effectiveName = useMemo( | ||
| 72 | () => (nameTouched ? name : slugifyCustomToolsAppName(suggestCustomToolsAppName(sourceValue))), | ||
| 73 | [name, nameTouched, sourceValue], | ||
| 74 | ); | ||
| 75 | const slug = effectiveName; | ||
| 76 | const slugAlreadyExists = useSlugAlreadyExists(slug); | ||
| 77 | |||
| 78 | useEffect(() => { | ||
| 79 | if (kind !== "local-directory" || !path.trim().startsWith("/")) { | ||
| 80 | setPathListing(null); | ||
| 81 | return; | ||
| 82 | } | ||
| 83 | let active = true; | ||
| 84 | const timeout = window.setTimeout(() => { | ||
| 85 | void Effect.runPromiseExit(listCustomToolDirectoriesEffect({ path: path.trim() })).then( | ||
| 86 | (exit) => { | ||
| 87 | if (!active) return; | ||
| 88 | setPathListing(Exit.isSuccess(exit) ? exit.value : null); | ||
| 89 | }, | ||
| 90 | ); | ||
| 91 | }, 300); | ||
| 92 | return () => { | ||
| 93 | active = false; | ||
| 94 | window.clearTimeout(timeout); | ||
| 95 | }; | ||
| 96 | }, [kind, path]); | ||
| 97 | |||
| 98 | const submit = async () => { | ||
| 99 | const nextFieldError = | ||
| 100 | kind === "git" | ||
| 101 | ? validateGitSourceUrl(url) | ||
| 102 | : path.trim().startsWith("/") | ||
| 103 | ? null | ||
| 104 | : "Enter an absolute directory path."; | ||
| 105 | const nextNameError = validateCustomToolsAppSlug(slug); | ||
| 106 | setFieldError(nextFieldError); | ||
| 107 | setNameError(nextNameError); | ||
| 108 | setSyncError(null); | ||
| 109 | if (nextFieldError || nextNameError) return; | ||
| 110 | if (slugAlreadyExists) { | ||
| 111 | setSyncError(`An integration named "${slug}" already exists. Choose another source name.`); | ||
| 112 | return; | ||
| 113 | } | ||
| 114 | |||
| 115 | setSyncing(true); | ||
| 116 | const parsedGitUrl = kind === "git" ? parseGitSourceUrl(url) : null; | ||
| 117 | const createExit = await Effect.runPromiseExit( | ||
| 118 | createCustomToolSourceEffect( | ||
| 119 | kind === "git" | ||
| 120 | ? { | ||
| 121 | kind: "git", | ||
| 122 | slug, | ||
| 123 | app: slug, | ||
| 124 | url: parsedGitUrl?.ok ? parsedGitUrl.url : url.trim(), | ||
| 125 | ...(ref.trim() ? { ref: ref.trim() } : {}), | ||
| 126 | ...(token.trim() ? { token: token.trim() } : {}), | ||
| 127 | } | ||
| 128 | : { kind: "local-directory", slug, app: slug, path: path.trim() }, | ||
| 129 | ), | ||
| 130 | ); | ||
| 131 | if (Exit.isFailure(createExit)) { | ||
| 132 | setSyncError("Failed to create custom tools source."); | ||
| 133 | setSyncing(false); | ||
| 134 | return; | ||
| 135 | } | ||
| 136 | const syncExit = await Effect.runPromiseExit( | ||
| 137 | syncCustomToolSourceEffect(createExit.value.source.slug), | ||
| 138 | ); | ||
| 139 | if (Exit.isFailure(syncExit)) { | ||
| 140 | setSyncError("Failed to sync custom tools source."); | ||
| 141 | setSyncing(false); | ||
| 142 | return; | ||
| 143 | } | ||
| 144 | const result = syncExit.value; | ||
| 145 | if (result.status === "failed") { | ||
| 146 | setSyncError(formatSyncErrors(result).join("\n") || "Sync failed."); | ||
| 147 | setSyncing(false); | ||
| 148 | return; | ||
| 149 | } | ||
| 150 | setSyncing(false); | ||
| 151 | props.onComplete(createExit.value.source.app); | ||
| 152 | }; | ||
| 153 | |||
| 154 | return ( | ||
| 155 | <div className="flex flex-1 flex-col gap-6"> | ||
| 156 | <div> | ||
| 157 | <h1 className="text-xl font-semibold text-foreground">Add custom tools</h1> | ||
| 158 | <p className="mt-1 text-[13px] text-muted-foreground"> | ||
| 159 | Sync tools from a Git repository, then publish them to the Apps catalog. | ||
| 160 | </p> | ||
| 161 | </div> | ||
| 162 | |||
| 163 | <CardStack> | ||
| 164 | <CardStackContent className="border-t-0"> | ||
| 165 | {allowLocalDirectory && ( | ||
| 166 | <CardStackEntryField | ||
| 167 | label="Source type" | ||
| 168 | description="- Git is portable. Local directories are local-only." | ||
| 169 | > | ||
| 170 | <div className="flex gap-2"> | ||
| 171 | <Button | ||
| 172 | type="button" | ||
| 173 | variant={kind === "git" ? "default" : "outline"} | ||
| 174 | onClick={() => setKind("git")} | ||
| 175 | disabled={syncing} | ||
| 176 | > | ||
| 177 | Git repository | ||
| 178 | </Button> | ||
| 179 | <Button | ||
| 180 | type="button" | ||
| 181 | variant={kind === "local-directory" ? "default" : "outline"} | ||
| 182 | onClick={() => setKind("local-directory")} | ||
| 183 | disabled={syncing} | ||
| 184 | > | ||
| 185 | Directory path | ||
| 186 | </Button> | ||
| 187 | </div> | ||
| 188 | </CardStackEntryField> | ||
| 189 | )} | ||
| 190 | |||
| 191 | {kind === "git" ? ( | ||
| 192 | <> | ||
| 193 | <CardStackEntryField label="Git repository" description="- Any https Git remote."> | ||
| 194 | <div className="space-y-1.5"> | ||
| 195 | <Input | ||
| 196 | value={url} | ||
| 197 | onChange={(event) => { | ||
| 198 | setUrl((event.target as HTMLInputElement).value); | ||
| 199 | setFieldError(null); | ||
| 200 | setSyncError(null); | ||
| 201 | }} | ||
| 202 | onBlur={() => setFieldError(validateGitSourceUrl(url))} | ||
| 203 | placeholder="https://github.com/acme/tools.git" | ||
| 204 | className="font-mono text-sm" | ||
| 205 | aria-invalid={fieldError ? true : undefined} | ||
| 206 | /> | ||
| 207 | {fieldError && <p className="text-xs text-destructive">{fieldError}</p>} | ||
| 208 | </div> | ||
| 209 | </CardStackEntryField> | ||
| 210 | <CardStackEntryField | ||
| 211 | label="Ref (optional)" | ||
| 212 | description="- Branch, tag, or commit SHA." | ||
| 213 | > | ||
| 214 | <Input | ||
| 215 | value={ref} | ||
| 216 | onChange={(event) => setRef((event.target as HTMLInputElement).value)} | ||
| 217 | placeholder="main" | ||
| 218 | className="font-mono text-sm" | ||
| 219 | disabled={syncing} | ||
| 220 | /> | ||
| 221 | </CardStackEntryField> | ||
| 222 | <CardStackEntryField | ||
| 223 | label="Token (optional)" | ||
| 224 | description="- For private repositories. Never shown after saving." | ||
| 225 | > | ||
| 226 | <Input | ||
| 227 | value={token} | ||
| 228 | type="password" | ||
| 229 | onChange={(event) => setToken((event.target as HTMLInputElement).value)} | ||
| 230 | autoComplete="off" | ||
| 231 | className="font-mono text-sm" | ||
| 232 | disabled={syncing} | ||
| 233 | /> | ||
| 234 | </CardStackEntryField> | ||
| 235 | </> | ||
| 236 | ) : ( | ||
| 237 | <CardStackEntryField | ||
| 238 | label="Directory path" | ||
| 239 | description="- Absolute path on this machine." | ||
| 240 | > | ||
| 241 | <div className="space-y-1.5"> | ||
| 242 | <div className="flex gap-2"> | ||
| 243 | <Input | ||
| 244 | value={path} | ||
| 245 | onChange={(event) => { | ||
| 246 | setPath((event.target as HTMLInputElement).value); | ||
| 247 | setFieldError(null); | ||
| 248 | setSyncError(null); | ||
| 249 | }} | ||
| 250 | onBlur={() => { | ||
| 251 | if (!path.trim().startsWith("/")) return; | ||
| 252 | void Effect.runPromiseExit( | ||
| 253 | listCustomToolDirectoriesEffect({ path: path.trim() }), | ||
| 254 | ).then((exit) => { | ||
| 255 | setPathListing(Exit.isSuccess(exit) ? exit.value : null); | ||
| 256 | }); | ||
| 257 | }} | ||
| 258 | placeholder="/Users/me/tools" | ||
| 259 | className="font-mono text-sm" | ||
| 260 | aria-invalid={fieldError ? true : undefined} | ||
| 261 | /> | ||
| 262 | <Button | ||
| 263 | type="button" | ||
| 264 | variant="outline" | ||
| 265 | onClick={() => setBrowseOpen(true)} | ||
| 266 | disabled={syncing} | ||
| 267 | > | ||
| 268 | Browse | ||
| 269 | </Button> | ||
| 270 | </div> | ||
| 271 | {pathListing && <DirectorySourceStatus listing={pathListing} />} | ||
| 272 | {fieldError && <p className="text-xs text-destructive">{fieldError}</p>} | ||
| 273 | </div> | ||
| 274 | </CardStackEntryField> | ||
| 275 | )} | ||
| 276 | |||
| 277 | <CardStackEntryField | ||
| 278 | label="Source name" | ||
| 279 | description="- Lowercase letters, numbers, and hyphens." | ||
| 280 | > | ||
| 281 | <div className="space-y-1.5"> | ||
| 282 | <Input | ||
| 283 | value={effectiveName} | ||
| 284 | onChange={(event) => { | ||
| 285 | setNameTouched(true); | ||
| 286 | setName(slugifyCustomToolsAppName((event.target as HTMLInputElement).value)); | ||
| 287 | setNameError(null); | ||
| 288 | setSyncError(null); | ||
| 289 | }} | ||
| 290 | placeholder="custom-tools" | ||
| 291 | className="text-sm" | ||
| 292 | aria-invalid={nameError ? true : undefined} | ||
| 293 | /> | ||
| 294 | {nameError && <p className="text-xs text-destructive">{nameError}</p>} | ||
| 295 | {slugAlreadyExists && !syncing && !nameError && ( | ||
| 296 | <p className="text-xs text-destructive"> | ||
| 297 | An integration named "{slug}" already exists. | ||
| 298 | </p> | ||
| 299 | )} | ||
| 300 | </div> | ||
| 301 | </CardStackEntryField> | ||
| 302 | </CardStackContent> | ||
| 303 | </CardStack> | ||
| 304 | |||
| 305 | {syncError && <FormErrorAlert message={syncError} />} | ||
| 306 | |||
| 307 | <FloatActions> | ||
| 308 | <Button type="button" variant="ghost" onClick={() => props.onCancel()} disabled={syncing}> | ||
| 309 | Cancel | ||
| 310 | </Button> | ||
| 311 | <Button type="button" onClick={() => void submit()} disabled={syncing} loading={syncing}> | ||
| 312 | Sync source | ||
| 313 | </Button> | ||
| 314 | </FloatActions> | ||
| 315 | {browseOpen && ( | ||
| 316 | <DirectoryBrowserModal | ||
| 317 | initialPath={path.trim() || undefined} | ||
| 318 | onClose={() => setBrowseOpen(false)} | ||
| 319 | onSelect={(selectedPath) => { | ||
| 320 | setPath(selectedPath); | ||
| 321 | setFieldError(null); | ||
| 322 | setSyncError(null); | ||
| 323 | setBrowseOpen(false); | ||
| 324 | }} | ||
| 325 | /> | ||
| 326 | )} | ||
| 327 | </div> | ||
| 328 | ); | ||
| 329 | } | ||
| 330 | |||
| 331 | function DirectoryBrowserModal(props: { | ||
| 332 | readonly initialPath?: string; | ||
| 333 | readonly onSelect: (path: string) => void; | ||
| 334 | readonly onClose: () => void; | ||
| 335 | }) { | ||
| 336 | const [currentPath, setCurrentPath] = useState(props.initialPath); | ||
| 337 | const [includeHidden, setIncludeHidden] = useState(false); | ||
| 338 | const [listing, setListing] = useState<CustomToolsDirectoryListing | null>(null); | ||
| 339 | const [loading, setLoading] = useState(true); | ||
| 340 | const [error, setError] = useState<string | null>(null); | ||
| 341 | |||
| 342 | useEffect(() => { | ||
| 343 | let active = true; | ||
| 344 | setLoading(true); | ||
| 345 | setError(null); | ||
| 346 | void Effect.runPromiseExit( | ||
| 347 | listCustomToolDirectoriesEffect({ path: currentPath, includeHidden }), | ||
| 348 | ).then((exit) => { | ||
| 349 | if (!active) return; | ||
| 350 | setLoading(false); | ||
| 351 | if (Exit.isFailure(exit)) { | ||
| 352 | setError("Unable to read this directory."); | ||
| 353 | return; | ||
| 354 | } | ||
| 355 | setListing(exit.value); | ||
| 356 | setCurrentPath(exit.value.path); | ||
| 357 | }); | ||
| 358 | return () => { | ||
| 359 | active = false; | ||
| 360 | }; | ||
| 361 | }, [currentPath, includeHidden]); | ||
| 362 | |||
| 363 | const rows = listing ? directoryBrowserRows(listing) : []; | ||
| 364 | const selectedPath = listing?.path ?? currentPath ?? ""; | ||
| 365 | const canSelect = Boolean(selectedPath) && !loading && !error; | ||
| 366 | const hasToolFiles = (listing?.source.toolFiles.length ?? 0) > 0; | ||
| 367 | |||
| 368 | return ( | ||
| 369 | <Dialog open onOpenChange={(open) => (open ? undefined : props.onClose())}> | ||
| 370 | <DialogContent className="sm:max-w-[620px]"> | ||
| 371 | <DialogHeader className="border-b border-border/60 pb-4"> | ||
| 372 | <DialogTitle className="text-base">Choose directory</DialogTitle> | ||
| 373 | <DialogDescription className="font-mono text-xs break-all"> | ||
| 374 | {selectedPath || "Home"} | ||
| 375 | </DialogDescription> | ||
| 376 | {listing && <DirectorySourceStatus listing={listing} />} | ||
| 377 | </DialogHeader> | ||
| 378 | <div className="space-y-3"> | ||
| 379 | <Label className="flex items-center gap-2 text-sm text-muted-foreground"> | ||
| 380 | <Checkbox | ||
| 381 | checked={includeHidden} | ||
| 382 | onCheckedChange={(checked) => setIncludeHidden(checked === true)} | ||
| 383 | /> | ||
| 384 | Show hidden directories | ||
| 385 | </Label> | ||
| 386 | <div className="max-h-[340px] overflow-auto rounded-md border border-border"> | ||
| 387 | {loading ? ( | ||
| 388 | <div className="px-3 py-8 text-center text-sm text-muted-foreground">Loading...</div> | ||
| 389 | ) : error ? ( | ||
| 390 | <div className="px-3 py-8 text-center text-sm text-destructive">{error}</div> | ||
| 391 | ) : rows.length === 0 ? ( | ||
| 392 | <div className="px-3 py-8 text-center text-sm text-muted-foreground"> | ||
| 393 | No subdirectories | ||
| 394 | </div> | ||
| 395 | ) : ( | ||
| 396 | <div role="listbox" aria-label="Directories" className="divide-y divide-border"> | ||
| 397 | {rows.map((row) => ( | ||
| 398 | <Button | ||
| 399 | key={`${row.kind}:${row.path}`} | ||
| 400 | type="button" | ||
| 401 | variant="ghost" | ||
| 402 | className="h-auto w-full justify-between gap-3 rounded-none px-3 py-2 text-left text-sm font-normal hover:bg-accent focus-visible:bg-accent focus-visible:outline-none" | ||
| 403 | onClick={() => setCurrentPath(row.path)} | ||
| 404 | > | ||
| 405 | <span className="min-w-0 truncate font-mono"> | ||
| 406 | {row.kind === "parent" ? ".." : row.name} | ||
| 407 | </span> | ||
| 408 | <span className="flex shrink-0 items-center gap-2"> | ||
| 409 | {row.kind === "dir" && row.hasTools && ( | ||
| 410 | <span className="rounded-sm border border-border bg-secondary px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"> | ||
| 411 | tools | ||
| 412 | </span> | ||
| 413 | )} | ||
| 414 | {row.kind === "dir" && row.isSymlink && ( | ||
| 415 | <span className="font-mono text-[11px] text-muted-foreground">symlink</span> | ||
| 416 | )} | ||
| 417 | </span> | ||
| 418 | </Button> | ||
| 419 | ))} | ||
| 420 | </div> | ||
| 421 | )} | ||
| 422 | </div> | ||
| 423 | </div> | ||
| 424 | <DialogFooter className="border-t border-border/60 pt-4"> | ||
| 425 | <Button type="button" variant="ghost" onClick={props.onClose}> | ||
| 426 | Cancel | ||
| 427 | </Button> | ||
| 428 | <Button type="button" onClick={() => props.onSelect(selectedPath)} disabled={!canSelect}> | ||
| 429 | {hasToolFiles ? "Select" : "Select anyway"} | ||
| 430 | </Button> | ||
| 431 | </DialogFooter> | ||
| 432 | </DialogContent> | ||
| 433 | </Dialog> | ||
| 434 | ); | ||
| 435 | } | ||
| 436 | |||
| 437 | function DirectorySourceStatus(props: { readonly listing: CustomToolsDirectoryListing }) { | ||
| 438 | const verdict = directorySourceVerdict(props.listing); | ||
| 439 | if (verdict.type === "valid") { | ||
| 440 | return ( | ||
| 441 | <p className="mt-2 text-xs text-foreground"> | ||
| 442 | {verdict.message}:{" "} | ||
| 443 | {verdict.visibleTools.map((tool, index) => ( | ||
| 444 | <span key={tool}> | ||
| 445 | {index > 0 ? ", " : ""} | ||
| 446 | <span className="font-mono">{tool}</span> | ||
| 447 | </span> | ||
| 448 | ))} | ||
| 449 | {verdict.moreCount > 0 && ( | ||
| 450 | <> | ||
| 451 | {", "} | ||
| 452 | <span className="font-mono">+{verdict.moreCount} more</span> | ||
| 453 | </> | ||
| 454 | )} | ||
| 455 | </p> | ||
| 456 | ); | ||
| 457 | } | ||
| 458 | return <p className="mt-2 text-xs text-muted-foreground">{verdict.message}</p>; | ||
| 459 | } | ||
| @@ -0,0 +1,292 @@ | |||
| 1 | import { useCallback, 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(props: { readonly integrationId: string }) { | ||
| 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 | const sourcesForApp = useCallback( | ||
| 50 | (sources: readonly AppSourceRecord[]): readonly AppSourceRecord[] => | ||
| 51 | sources.filter((source) => source.app === props.integrationId), | ||
| 52 | [props.integrationId], | ||
| 53 | ); | ||
| 54 | |||
| 55 | const loadSources = async () => { | ||
| 56 | setLoadState({ status: "loading" }); | ||
| 57 | const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect()); | ||
| 58 | if (Exit.isFailure(exit)) { | ||
| 59 | setLoadState({ status: "error", message: "Failed to load custom tools sources." }); | ||
| 60 | return; | ||
| 61 | } | ||
| 62 | setLoadState({ status: "ready", sources: sourcesForApp(exit.value.sources) }); | ||
| 63 | }; | ||
| 64 | |||
| 65 | useEffect(() => { | ||
| 66 | let active = true; | ||
| 67 | void (async () => { | ||
| 68 | const exit = await Effect.runPromiseExit(listCustomToolSourcesEffect()); | ||
| 69 | if (!active) return; | ||
| 70 | if (Exit.isFailure(exit)) { | ||
| 71 | setLoadState({ status: "error", message: "Failed to load custom tools sources." }); | ||
| 72 | return; | ||
| 73 | } | ||
| 74 | setLoadState({ status: "ready", sources: sourcesForApp(exit.value.sources) }); | ||
| 75 | })(); | ||
| 76 | return () => { | ||
| 77 | active = false; | ||
| 78 | }; | ||
| 79 | }, [props.integrationId, sourcesForApp]); | ||
| 80 | |||
| 81 | const syncSource = async (source: AppSourceRecord) => { | ||
| 82 | setSyncingSlug(source.slug); | ||
| 83 | setNotice((current) => ({ ...current, [source.slug]: undefined })); | ||
| 84 | const beforeTools = | ||
| 85 | source.status.type === "published" || source.status.type === "up-to-date" | ||
| 86 | ? source.status.tools | ||
| 87 | : []; | ||
| 88 | const exit = await Effect.runPromiseExit(syncCustomToolSourceEffect(source.slug)); | ||
| 89 | if (Exit.isFailure(exit)) { | ||
| 90 | setNotice((current) => ({ | ||
| 91 | ...current, | ||
| 92 | [source.slug]: { | ||
| 93 | status: "failed", | ||
| 94 | message: "Sync failed.", | ||
| 95 | added: [], | ||
| 96 | removed: [], | ||
| 97 | errors: ["Failed to sync custom tools source."], | ||
| 98 | }, | ||
| 99 | })); | ||
| 100 | setSyncingSlug(null); | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | setNotice((current) => ({ | ||
| 104 | ...current, | ||
| 105 | [source.slug]: syncNoticeFromResult(exit.value, beforeTools), | ||
| 106 | })); | ||
| 107 | await loadSources(); | ||
| 108 | setSyncingSlug(null); | ||
| 109 | }; | ||
| 110 | |||
| 111 | const removeSource = async (source: AppSourceRecord) => { | ||
| 112 | setRemovingSlug(source.slug); | ||
| 113 | setRemoveError(null); | ||
| 114 | const exit = await Effect.runPromiseExit(removeCustomToolSourceEffect(source.slug)); | ||
| 115 | if (Exit.isFailure(exit)) { | ||
| 116 | setRemoveError("Failed to remove custom tools source."); | ||
| 117 | setRemovingSlug(null); | ||
| 118 | return; | ||
| 119 | } | ||
| 120 | await loadSources(); | ||
| 121 | setRemovingSlug(null); | ||
| 122 | window.location.assign(integrationsHref()); | ||
| 123 | }; | ||
| 124 | |||
| 125 | return ( | ||
| 126 | <div className="mx-auto max-w-3xl space-y-6 px-6 py-8"> | ||
| 127 | {loadState.status === "loading" && ( | ||
| 128 | <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground"> | ||
| 129 | Loading sources... | ||
| 130 | </div> | ||
| 131 | )} | ||
| 132 | |||
| 133 | {loadState.status === "error" && ( | ||
| 134 | <Alert variant="destructive"> | ||
| 135 | <AlertTitle>Failed to load sources</AlertTitle> | ||
| 136 | <AlertDescription> | ||
| 137 | <div className="space-y-3"> | ||
| 138 | <p>{loadState.message}</p> | ||
| 139 | <Button type="button" size="sm" variant="outline" onClick={() => void loadSources()}> | ||
| 140 | Retry | ||
| 141 | </Button> | ||
| 142 | </div> | ||
| 143 | </AlertDescription> | ||
| 144 | </Alert> | ||
| 145 | )} | ||
| 146 | |||
| 147 | {loadState.status === "ready" && loadState.sources.length === 0 && ( | ||
| 148 | <div className="rounded-lg border border-border p-4"> | ||
| 149 | <p className="text-sm font-medium text-foreground">No custom tool sources</p> | ||
| 150 | <p className="mt-1 text-sm text-muted-foreground"> | ||
| 151 | Add a Git repository to publish tools into the Apps catalog. | ||
| 152 | </p> | ||
| 153 | </div> | ||
| 154 | )} | ||
| 155 | |||
| 156 | {loadState.status === "ready" && | ||
| 157 | loadState.sources.map((source) => ( | ||
| 158 | <SourceDetail | ||
| 159 | key={source.slug} | ||
| 160 | source={source} | ||
| 161 | notice={notice[source.slug] ?? null} | ||
| 162 | removeError={removeError} | ||
| 163 | syncing={syncingSlug === source.slug} | ||
| 164 | removing={removingSlug === source.slug} | ||
| 165 | onSync={() => void syncSource(source)} | ||
| 166 | onRemove={() => void removeSource(source)} | ||
| 167 | /> | ||
| 168 | ))} | ||
| 169 | </div> | ||
| 170 | ); | ||
| 171 | } | ||
| 172 | |||
| 173 | function SourceDetail(props: { | ||
| 174 | readonly source: AppSourceRecord; | ||
| 175 | readonly notice: SyncNoticeModel | null; | ||
| 176 | readonly removeError: string | null; | ||
| 177 | readonly syncing: boolean; | ||
| 178 | readonly removing: boolean; | ||
| 179 | readonly onSync: () => void; | ||
| 180 | readonly onRemove: () => void; | ||
| 181 | }) { | ||
| 182 | const { source, notice } = props; | ||
| 183 | const model = sourcePanelModel(source); | ||
| 184 | const failureLines = sourceFailureLines(source); | ||
| 185 | const noticeHasDetails = | ||
| 186 | notice !== null && | ||
| 187 | (notice.added.length > 0 || | ||
| 188 | notice.removed.length > 0 || | ||
| 189 | notice.errors.length > 0 || | ||
| 190 | notice.sourceRef !== undefined); | ||
| 191 | return ( | ||
| 192 | <div className="min-w-0 rounded-lg border border-border bg-card"> | ||
| 193 | <div className="flex items-start justify-between gap-4 border-b border-border p-4"> | ||
| 194 | <div className="min-w-0"> | ||
| 195 | <h3 className="truncate text-sm font-medium text-foreground">{model.title}</h3> | ||
| 196 | <p className="mt-1 truncate text-xs text-muted-foreground">{model.source}</p> | ||
| 197 | </div> | ||
| 198 | <div className="flex shrink-0 gap-2"> | ||
| 199 | <Button type="button" size="sm" onClick={props.onSync} loading={props.syncing}> | ||
| 200 | Sync | ||
| 201 | </Button> | ||
| 202 | <AlertDialog> | ||
| 203 | <AlertDialogTrigger asChild> | ||
| 204 | <Button | ||
| 205 | type="button" | ||
| 206 | size="sm" | ||
| 207 | variant="outline" | ||
| 208 | className="border-destructive/30 text-destructive hover:bg-destructive/10" | ||
| 209 | disabled={props.removing} | ||
| 210 | > | ||
| 211 | Remove | ||
| 212 | </Button> | ||
| 213 | </AlertDialogTrigger> | ||
| 214 | <AlertDialogContent size="sm"> | ||
| 215 | <AlertDialogHeader> | ||
| 216 | <AlertDialogTitle>Remove {source.app}?</AlertDialogTitle> | ||
| 217 | <AlertDialogDescription> | ||
| 218 | This removes {toolsCountLabel(model.tools.length)} from the catalog. The source is | ||
| 219 | untouched and can be added again later. | ||
| 220 | </AlertDialogDescription> | ||
| 221 | </AlertDialogHeader> | ||
| 222 | <AlertDialogFooter> | ||
| 223 | <AlertDialogCancel>Cancel</AlertDialogCancel> | ||
| 224 | <AlertDialogAction variant="destructive" onClick={props.onRemove}> | ||
| 225 | {props.removing ? "Removing..." : "Remove source"} | ||
| 226 | </AlertDialogAction> | ||
| 227 | </AlertDialogFooter> | ||
| 228 | </AlertDialogContent> | ||
| 229 | </AlertDialog> | ||
| 230 | </div> | ||
| 231 | </div> | ||
| 232 | |||
| 233 | <div className="space-y-5 p-4"> | ||
| 234 | <div className="grid gap-3 text-sm sm:grid-cols-3"> | ||
| 235 | <Meta label="Status" value={model.status} /> | ||
| 236 | <Meta label="Source ref" value={model.sourceRef} mono /> | ||
| 237 | <Meta label="Tools" value={toolsCountLabel(model.tools.length)} /> | ||
| 238 | </div> | ||
| 239 | |||
| 240 | {failureLines.length > 0 && ( | ||
| 241 | <Alert variant="destructive"> | ||
| 242 | <AlertTitle>Last sync failed</AlertTitle> | ||
| 243 | <AlertDescription> | ||
| 244 | <div className="space-y-1"> | ||
| 245 | {failureLines.map((line) => ( | ||
| 246 | <p key={line}>{line}</p> | ||
| 247 | ))} | ||
| 248 | </div> | ||
| 249 | </AlertDescription> | ||
| 250 | </Alert> | ||
| 251 | )} | ||
| 252 | |||
| 253 | {notice && ( | ||
| 254 | <Alert variant={notice.status === "failed" ? "destructive" : "default"}> | ||
| 255 | <AlertTitle>{notice.message}</AlertTitle> | ||
| 256 | {noticeHasDetails && ( | ||
| 257 | <AlertDescription> | ||
| 258 | <div className="space-y-1"> | ||
| 259 | {notice.added.length > 0 && <p>Added: {notice.added.join(", ")}</p>} | ||
| 260 | {notice.removed.length > 0 && <p>Removed: {notice.removed.join(", ")}</p>} | ||
| 261 | {notice.errors.map((error) => ( | ||
| 262 | <p key={error}>{error}</p> | ||
| 263 | ))} | ||
| 264 | {notice.sourceRef && ( | ||
| 265 | <p className="font-mono text-xs text-muted-foreground"> | ||
| 266 | Commit {notice.sourceRef} | ||
| 267 | </p> | ||
| 268 | )} | ||
| 269 | </div> | ||
| 270 | </AlertDescription> | ||
| 271 | )} | ||
| 272 | </Alert> | ||
| 273 | )} | ||
| 274 | |||
| 275 | {props.removeError && <FormErrorAlert message={props.removeError} />} | ||
| 276 | </div> | ||
| 277 | </div> | ||
| 278 | ); | ||
| 279 | } | ||
| 280 | |||
| 281 | function Meta(props: { readonly label: string; readonly value: string; readonly mono?: boolean }) { | ||
| 282 | return ( | ||
| 283 | <div className="min-w-0"> | ||
| 284 | <p className="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground"> | ||
| 285 | {props.label} | ||
| 286 | </p> | ||
| 287 | <p className={props.mono ? "mt-1 truncate font-mono text-xs" : "mt-1 truncate text-sm"}> | ||
| 288 | {props.value} | ||
| 289 | </p> | ||
| 290 | </div> | ||
| 291 | ); | ||
| 292 | } | ||
| @@ -0,0 +1,375 @@ | |||
| 1 | import { Data, Effect, Schema } from "effect"; | ||
| 2 | import { | ||
| 3 | EXECUTOR_ORG_HEADER, | ||
| 4 | getActiveOrgSlug, | ||
| 5 | getExecutorApiBaseUrl, | ||
| 6 | getExecutorServerAuthorizationHeader, | ||
| 7 | } from "@executor-js/react/api/server-connection"; | ||
| 8 | |||
| 9 | export const CUSTOM_TOOLS_PLUGIN_KEY = "apps"; | ||
| 10 | export const CUSTOM_TOOLS_LABEL = "Custom tools"; | ||
| 11 | |||
| 12 | export type AppSourceKind = "git" | "local-directory"; | ||
| 13 | |||
| 14 | export interface SyncDiagnostic { | ||
| 15 | readonly stage: "source" | "discover" | "bundle" | "collect" | "project"; | ||
| 16 | readonly message: string; | ||
| 17 | readonly diagnostics?: readonly { readonly path: string; readonly message: string }[]; | ||
| 18 | } | ||
| 19 | |||
| 20 | export type SourceStatus = | ||
| 21 | | { readonly type: "pending" } | ||
| 22 | | { | ||
| 23 | readonly type: "published" | "up-to-date"; | ||
| 24 | readonly at: number; | ||
| 25 | readonly tools: readonly string[]; | ||
| 26 | } | ||
| 27 | | { readonly type: "failed"; readonly at: number; readonly errors: readonly SyncDiagnostic[] }; | ||
| 28 | |||
| 29 | export type AppSourceRecord = | ||
| 30 | | { | ||
| 31 | readonly slug: string; | ||
| 32 | readonly app: string; | ||
| 33 | readonly kind: "git"; | ||
| 34 | readonly config: { | ||
| 35 | readonly kind: "git"; | ||
| 36 | readonly url: string; | ||
| 37 | readonly ref?: string; | ||
| 38 | readonly tokenProvider?: string; | ||
| 39 | readonly tokenItemId?: string; | ||
| 40 | }; | ||
| 41 | readonly sourceRef?: string; | ||
| 42 | readonly description?: string; | ||
| 43 | readonly status: SourceStatus; | ||
| 44 | readonly updatedAt: number; | ||
| 45 | } | ||
| 46 | | { | ||
| 47 | readonly slug: string; | ||
| 48 | readonly app: string; | ||
| 49 | readonly kind: "local-directory"; | ||
| 50 | readonly config: { readonly kind: "local-directory"; readonly path: string }; | ||
| 51 | readonly sourceRef?: string; | ||
| 52 | readonly description?: string; | ||
| 53 | readonly status: SourceStatus; | ||
| 54 | readonly updatedAt: number; | ||
| 55 | }; | ||
| 56 | |||
| 57 | export interface SourcesListResponse { | ||
| 58 | readonly sources: readonly AppSourceRecord[]; | ||
| 59 | } | ||
| 60 | |||
| 61 | export interface SourceDetailResponse { | ||
| 62 | readonly source: AppSourceRecord | null; | ||
| 63 | } | ||
| 64 | |||
| 65 | export type CreateSourceRequest = | ||
| 66 | | { | ||
| 67 | readonly kind: "git"; | ||
| 68 | readonly slug?: string; | ||
| 69 | readonly app?: string; | ||
| 70 | readonly url: string; | ||
| 71 | readonly ref?: string; | ||
| 72 | readonly token?: string; | ||
| 73 | } | ||
| 74 | | { | ||
| 75 | readonly kind: "local-directory"; | ||
| 76 | readonly slug?: string; | ||
| 77 | readonly app?: string; | ||
| 78 | readonly path: string; | ||
| 79 | }; | ||
| 80 | |||
| 81 | export interface CreateSourceResponse { | ||
| 82 | readonly source: AppSourceRecord; | ||
| 83 | } | ||
| 84 | |||
| 85 | export type SyncSourceResult = | ||
| 86 | | { | ||
| 87 | readonly status: "published" | "up-to-date"; | ||
| 88 | readonly sourceRef: string; | ||
| 89 | readonly tools: readonly string[]; | ||
| 90 | readonly errors?: undefined; | ||
| 91 | } | ||
| 92 | | { | ||
| 93 | readonly status: "failed"; | ||
| 94 | readonly sourceRef?: string; | ||
| 95 | readonly tools: readonly string[]; | ||
| 96 | readonly errors: readonly SyncDiagnostic[]; | ||
| 97 | }; | ||
| 98 | |||
| 99 | export type CustomToolsFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; | ||
| 100 | |||
| 101 | const customToolsFetch: CustomToolsFetch = (input, init = {}) => { | ||
| 102 | const headers = new Headers(init.headers); | ||
| 103 | const authorization = getExecutorServerAuthorizationHeader(); | ||
| 104 | if (authorization && !headers.has("authorization")) { | ||
| 105 | headers.set("authorization", authorization); | ||
| 106 | } | ||
| 107 | const orgSlug = getActiveOrgSlug(); | ||
| 108 | if (orgSlug && !headers.has(EXECUTOR_ORG_HEADER)) { | ||
| 109 | headers.set(EXECUTOR_ORG_HEADER, orgSlug); | ||
| 110 | } | ||
| 111 | const url = | ||
| 112 | typeof input === "string" && input.startsWith("/") | ||
| 113 | ? new URL(input, getExecutorApiBaseUrl()).toString() | ||
| 114 | : input; | ||
| 115 | return fetch(url, { ...init, headers }); | ||
| 116 | }; | ||
| 117 | |||
| 118 | export interface CustomToolsDirectoryEntry { | ||
| 119 | readonly name: string; | ||
| 120 | readonly path: string; | ||
| 121 | readonly isSymlink: boolean; | ||
| 122 | readonly hasTools: boolean; | ||
| 123 | } | ||
| 124 | |||
| 125 | export interface CustomToolsDirectorySourceShape { | ||
| 126 | readonly toolFiles: readonly string[]; | ||
| 127 | readonly skipped: readonly string[]; | ||
| 128 | readonly hasPackageJson: boolean; | ||
| 129 | } | ||
| 130 | |||
| 131 | export interface CustomToolsDirectoryListing { | ||
| 132 | readonly path: string; | ||
| 133 | readonly parent: string | null; | ||
| 134 | readonly dirs: readonly CustomToolsDirectoryEntry[]; | ||
| 135 | readonly source: CustomToolsDirectorySourceShape; | ||
| 136 | } | ||
| 137 | |||
| 138 | export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{ | ||
| 139 | readonly message: string; | ||
| 140 | }> {} | ||
| 141 | |||
| 142 | export const slugifyCustomToolsAppName = (value: string): string => | ||
| 143 | value | ||
| 144 | .trim() | ||
| 145 | .toLowerCase() | ||
| 146 | .replace(/[^a-z0-9-]+/g, "-") | ||
| 147 | .replace(/^-+|-+$/g, "") | ||
| 148 | .slice(0, 80); | ||
| 149 | |||
| 150 | export const validateCustomToolsAppSlug = (value: string): string | null => { | ||
| 151 | if (!value.trim()) return "Enter a source name."; | ||
| 152 | if (value !== slugifyCustomToolsAppName(value)) { | ||
| 153 | return "Use lowercase letters, numbers, and hyphens."; | ||
| 154 | } | ||
| 155 | return null; | ||
| 156 | }; | ||
| 157 | |||
| 158 | export const parseGitSourceUrl = ( | ||
| 159 | raw: string, | ||
| 160 | ): | ||
| 161 | | { readonly ok: true; readonly url: string; readonly name: string } | ||
| 162 | | { readonly ok: false; readonly message: string } => { | ||
| 163 | const value = raw.trim(); | ||
| 164 | if (!value) return { ok: false, message: "Enter a Git repository URL." }; | ||
| 165 | if (!URL.canParse(value)) return { ok: false, message: "Enter an https Git repository URL." }; | ||
| 166 | const parsed = new URL(value); | ||
| 167 | if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { | ||
| 168 | return { ok: false, message: "Git repository URLs must use http or https." }; | ||
| 169 | } | ||
| 170 | if (parsed.username || parsed.password) { | ||
| 171 | return { ok: false, message: "Do not include credentials in the URL. Use the token field." }; | ||
| 172 | } | ||
| 173 | const segments = parsed.pathname.split("/").filter(Boolean); | ||
| 174 | const last = segments.at(-1)?.replace(/\.git$/, "") ?? ""; | ||
| 175 | if (!last) return { ok: false, message: "Enter a repository URL with a path." }; | ||
| 176 | parsed.hash = ""; | ||
| 177 | return { ok: true, url: parsed.toString(), name: last }; | ||
| 178 | }; | ||
| 179 | |||
| 180 | export const suggestCustomToolsAppName = (urlOrPath: string): string => { | ||
| 181 | const parsed = parseGitSourceUrl(urlOrPath); | ||
| 182 | if (parsed.ok) return parsed.name; | ||
| 183 | const pathParts = urlOrPath.split("/").filter(Boolean); | ||
| 184 | return pathParts.at(-1) ?? ""; | ||
| 185 | }; | ||
| 186 | |||
| 187 | export const validateGitSourceUrl = (url: string): string | null => { | ||
| 188 | const parsed = parseGitSourceUrl(url); | ||
| 189 | return parsed.ok ? null : parsed.message; | ||
| 190 | }; | ||
| 191 | |||
| 192 | const decodeJsonText = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); | ||
| 193 | const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); | ||
| 194 | |||
| 195 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 196 | value !== null && typeof value === "object" && !Array.isArray(value); | ||
| 197 | |||
| 198 | const responseErrorMessage = (body: unknown, fallback: string): string => | ||
| 199 | isRecord(body) && typeof body.message === "string" | ||
| 200 | ? body.message | ||
| 201 | : isRecord(body) && typeof body.error === "string" | ||
| 202 | ? body.error | ||
| 203 | : fallback; | ||
| 204 | |||
| 205 | const parseJsonResponseEffect = <A>( | ||
| 206 | response: Response, | ||
| 207 | fallback: string, | ||
| 208 | ): Effect.Effect<A, CustomToolsClientError> => | ||
| 209 | Effect.gen(function* () { | ||
| 210 | const text = yield* Effect.tryPromise({ | ||
| 211 | try: () => response.text(), | ||
| 212 | catch: () => new CustomToolsClientError({ message: fallback }), | ||
| 213 | }); | ||
| 214 | const body = | ||
| 215 | text.length > 0 | ||
| 216 | ? yield* decodeJsonText(text).pipe( | ||
| 217 | Effect.mapError(() => new CustomToolsClientError({ message: fallback })), | ||
| 218 | ) | ||
| 219 | : null; | ||
| 220 | if (!response.ok) { | ||
| 221 | return yield* new CustomToolsClientError({ | ||
| 222 | message: responseErrorMessage(body, fallback), | ||
| 223 | }); | ||
| 224 | } | ||
| 225 | return body as A; | ||
| 226 | }); | ||
| 227 | |||
| 228 | export const listCustomToolSourcesEffect = ( | ||
| 229 | fetchImpl: CustomToolsFetch = customToolsFetch, | ||
| 230 | ): Effect.Effect<SourcesListResponse, CustomToolsClientError> => | ||
| 231 | Effect.tryPromise({ | ||
| 232 | try: () => fetchImpl("/api/apps/sources", { credentials: "same-origin" }), | ||
| 233 | catch: () => new CustomToolsClientError({ message: "Failed to load custom tools sources." }), | ||
| 234 | }).pipe( | ||
| 235 | Effect.flatMap((response) => | ||
| 236 | parseJsonResponseEffect<SourcesListResponse>( | ||
| 237 | response, | ||
| 238 | "Failed to load custom tools sources.", | ||
| 239 | ), | ||
| 240 | ), | ||
| 241 | ); | ||
| 242 | |||
| 243 | export const listCustomToolSources = ( | ||
| 244 | fetchImpl: CustomToolsFetch = customToolsFetch, | ||
| 245 | ): Promise<SourcesListResponse> => Effect.runPromise(listCustomToolSourcesEffect(fetchImpl)); | ||
| 246 | |||
| 247 | export const getCustomToolSourceEffect = ( | ||
| 248 | slug: string, | ||
| 249 | fetchImpl: CustomToolsFetch = customToolsFetch, | ||
| 250 | ): Effect.Effect<SourceDetailResponse, CustomToolsClientError> => | ||
| 251 | Effect.tryPromise({ | ||
| 252 | try: () => | ||
| 253 | fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, { | ||
| 254 | credentials: "same-origin", | ||
| 255 | }), | ||
| 256 | catch: () => new CustomToolsClientError({ message: "Failed to load custom tools source." }), | ||
| 257 | }).pipe( | ||
| 258 | Effect.flatMap((response) => | ||
| 259 | parseJsonResponseEffect<SourceDetailResponse>( | ||
| 260 | response, | ||
| 261 | "Failed to load custom tools source.", | ||
| 262 | ), | ||
| 263 | ), | ||
| 264 | ); | ||
| 265 | |||
| 266 | export const createCustomToolSourceEffect = ( | ||
| 267 | input: CreateSourceRequest, | ||
| 268 | fetchImpl: CustomToolsFetch = customToolsFetch, | ||
| 269 | ): Effect.Effect<CreateSourceResponse, CustomToolsClientError> => | ||
| 270 | Effect.tryPromise({ | ||
| 271 | try: () => | ||
| 272 | fetchImpl("/api/apps/sources", { | ||
| 273 | method: "POST", | ||
| 274 | headers: { "content-type": "application/json" }, | ||
| 275 | credentials: "same-origin", | ||
| 276 | body: encodeJsonText(input), | ||
| 277 | }), | ||
| 278 | catch: () => new CustomToolsClientError({ message: "Failed to create custom tools source." }), | ||
| 279 | }).pipe( | ||
| 280 | Effect.flatMap((response) => | ||
| 281 | parseJsonResponseEffect<CreateSourceResponse>( | ||
| 282 | response, | ||
| 283 | "Failed to create custom tools source.", | ||
| 284 | ), | ||
| 285 | ), | ||
| 286 | ); | ||
| 287 | |||
| 288 | export const listCustomToolDirectoriesEffect = ( | ||
| 289 | input: { readonly path?: string; readonly includeHidden?: boolean } = {}, | ||
| 290 | fetchImpl: CustomToolsFetch = customToolsFetch, | ||
| 291 | ): Effect.Effect<CustomToolsDirectoryListing, CustomToolsClientError> => | ||
| 292 | Effect.tryPromise({ | ||
| 293 | try: () => { | ||
| 294 | const query = new URLSearchParams(); | ||
| 295 | if (input.path) query.set("path", input.path); | ||
| 296 | if (input.includeHidden) query.set("includeHidden", "true"); | ||
| 297 | const suffix = query.size > 0 ? `?${query.toString()}` : ""; | ||
| 298 | return fetchImpl(`/api/apps/fs/dirs${suffix}`, { credentials: "same-origin" }); | ||
| 299 | }, | ||
| 300 | catch: () => new CustomToolsClientError({ message: "Failed to browse directories." }), | ||
| 301 | }).pipe( | ||
| 302 | Effect.flatMap((response) => | ||
| 303 | parseJsonResponseEffect<CustomToolsDirectoryListing>( | ||
| 304 | response, | ||
| 305 | "Failed to browse directories.", | ||
| 306 | ), | ||
| 307 | ), | ||
| 308 | ); | ||
| 309 | |||
| 310 | export const syncCustomToolSourceEffect = ( | ||
| 311 | slug: string, | ||
| 312 | fetchImpl: CustomToolsFetch = customToolsFetch, | ||
| 313 | ): Effect.Effect<SyncSourceResult, CustomToolsClientError> => | ||
| 314 | Effect.tryPromise({ | ||
| 315 | try: () => | ||
| 316 | fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}/sync`, { | ||
| 317 | method: "POST", | ||
| 318 | credentials: "same-origin", | ||
| 319 | }), | ||
| 320 | catch: () => new CustomToolsClientError({ message: "Failed to sync custom tools source." }), | ||
| 321 | }).pipe( | ||
| 322 | Effect.flatMap((response) => | ||
| 323 | parseJsonResponseEffect<SyncSourceResult>(response, "Failed to sync custom tools source."), | ||
| 324 | ), | ||
| 325 | ); | ||
| 326 | |||
| 327 | export const removeCustomToolSourceEffect = ( | ||
| 328 | slug: string, | ||
| 329 | fetchImpl: CustomToolsFetch = customToolsFetch, | ||
| 330 | ): Effect.Effect<{ readonly removed: boolean }, CustomToolsClientError> => | ||
| 331 | Effect.tryPromise({ | ||
| 332 | try: () => | ||
| 333 | fetchImpl(`/api/apps/sources/${encodeURIComponent(slug)}`, { | ||
| 334 | method: "DELETE", | ||
| 335 | credentials: "same-origin", | ||
| 336 | }), | ||
| 337 | catch: () => new CustomToolsClientError({ message: "Failed to remove custom tools source." }), | ||
| 338 | }).pipe( | ||
| 339 | Effect.flatMap((response) => | ||
| 340 | parseJsonResponseEffect<{ readonly removed: boolean }>( | ||
| 341 | response, | ||
| 342 | "Failed to remove custom tools source.", | ||
| 343 | ), | ||
| 344 | ), | ||
| 345 | ); | ||
| 346 | |||
| 347 | export const syncStatusLabel = (result: SyncSourceResult): string => { | ||
| 348 | if (result.status === "published") return `Published ${result.tools.length} tools.`; | ||
| 349 | if (result.status === "up-to-date") return "Already up to date."; | ||
| 350 | return "Sync failed."; | ||
| 351 | }; | ||
| 352 | |||
| 353 | export const formatDiagnostics = ( | ||
| 354 | errors: readonly SyncDiagnostic[] | undefined, | ||
| 355 | ): readonly string[] => | ||
| 356 | (errors ?? []).flatMap((entry) => { | ||
| 357 | const head = `${entry.stage}: ${entry.message}`; | ||
| 358 | const details = entry.diagnostics?.map((d) => `${d.path}: ${d.message}`) ?? []; | ||
| 359 | return details.length > 0 ? [head, ...details] : [head]; | ||
| 360 | }); | ||
| 361 | |||
| 362 | export const formatSyncErrors = (result: SyncSourceResult): readonly string[] => | ||
| 363 | result.status === "failed" ? formatDiagnostics(result.errors) : []; | ||
| 364 | |||
| 365 | export const toolDiff = ( | ||
| 366 | before: readonly string[], | ||
| 367 | after: readonly string[], | ||
| 368 | ): { readonly added: readonly string[]; readonly removed: readonly string[] } => { | ||
| 369 | const beforeSet = new Set(before); | ||
| 370 | const afterSet = new Set(after); | ||
| 371 | return { | ||
| 372 | added: after.filter((tool) => !beforeSet.has(tool)), | ||
| 373 | removed: before.filter((tool) => !afterSet.has(tool)), | ||
| 374 | }; | ||
| 375 | }; | ||
| @@ -0,0 +1,267 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | |||
| 4 | import appsClientPlugin, { | ||
| 5 | CUSTOM_TOOLS_LABEL, | ||
| 6 | CUSTOM_TOOLS_PLUGIN_KEY, | ||
| 7 | createCustomToolSourceEffect, | ||
| 8 | formatSyncErrors, | ||
| 9 | listCustomToolDirectoriesEffect, | ||
| 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 { | ||
| 21 | directorySourceVerdict, | ||
| 22 | sourcePanelModel, | ||
| 23 | syncNoticeFromResult, | ||
| 24 | } from "./source-panel-model"; | ||
| 25 | import { directoryBrowserRows } from "./source-panel-model"; | ||
| 26 | |||
| 27 | const jsonResponse = (body: unknown, init?: ResponseInit): Response => | ||
| 28 | new Response(JSON.stringify(body), { | ||
| 29 | status: 200, | ||
| 30 | headers: { "content-type": "application/json" }, | ||
| 31 | ...init, | ||
| 32 | }); | ||
| 33 | |||
| 34 | const demoSource: AppSourceRecord = { | ||
| 35 | slug: "demo-tools", | ||
| 36 | app: "demo-tools", | ||
| 37 | kind: "git", | ||
| 38 | config: { | ||
| 39 | kind: "git", | ||
| 40 | url: "https://gitlab.com/acme/demo-tools.git", | ||
| 41 | ref: "main", | ||
| 42 | tokenProvider: "default", | ||
| 43 | tokenItemId: "apps/source-tokens/demo-tools", | ||
| 44 | }, | ||
| 45 | sourceRef: "abc123def456", | ||
| 46 | description: "Demo tools", | ||
| 47 | status: { | ||
| 48 | type: "published", | ||
| 49 | at: Date.parse("2026-07-06T12:00:00.000Z"), | ||
| 50 | tools: ["repo-summary", "stale-issues"], | ||
| 51 | }, | ||
| 52 | updatedAt: Date.parse("2026-07-06T12:00:00.000Z"), | ||
| 53 | }; | ||
| 54 | |||
| 55 | describe("custom tools console client", () => { | ||
| 56 | it("registers the Apps integration plugin", () => { | ||
| 57 | const plugin = appsClientPlugin({ sourceKinds: ["git"] }); | ||
| 58 | |||
| 59 | expect(plugin.id).toBe(CUSTOM_TOOLS_PLUGIN_KEY); | ||
| 60 | expect(plugin.integrationPlugin?.label).toBe(CUSTOM_TOOLS_LABEL); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("validates and suggests generic https Git repository URLs", () => { | ||
| 64 | expect(parseGitSourceUrl("https://github.com/UsefulSoftwareCo/executor.git")).toMatchObject({ | ||
| 65 | ok: true, | ||
| 66 | name: "executor", | ||
| 67 | url: "https://github.com/UsefulSoftwareCo/executor.git", | ||
| 68 | }); | ||
| 69 | expect(parseGitSourceUrl("https://gitlab.com/acme/tools")).toMatchObject({ | ||
| 70 | ok: true, | ||
| 71 | name: "tools", | ||
| 72 | }); | ||
| 73 | expect(parseGitSourceUrl("ssh://gitlab.com/acme/tools")).toMatchObject({ | ||
| 74 | ok: false, | ||
| 75 | message: "Git repository URLs must use http or https.", | ||
| 76 | }); | ||
| 77 | expect(parseGitSourceUrl("https://x:secret@gitlab.com/acme/tools")).toMatchObject({ | ||
| 78 | ok: false, | ||
| 79 | message: "Do not include credentials in the URL. Use the token field.", | ||
| 80 | }); | ||
| 81 | expect(suggestCustomToolsAppName("https://gitlab.com/acme/tools.git")).toBe("tools"); | ||
| 82 | expect(slugifyCustomToolsAppName("Acme Tools!")).toBe("acme-tools"); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("uses the new source routes and keeps token-bearing input out of read responses", async () => { | ||
| 86 | const calls: Array<{ readonly url: string; readonly init?: RequestInit }> = []; | ||
| 87 | let createBody = ""; | ||
| 88 | const fetchImpl: CustomToolsFetch = async (input, init) => { | ||
| 89 | const url = String(input); | ||
| 90 | calls.push({ url, init }); | ||
| 91 | if (url === "/api/apps/sources" && init?.method === "POST") { | ||
| 92 | createBody = String(init.body); | ||
| 93 | return jsonResponse({ source: demoSource }); | ||
| 94 | } | ||
| 95 | if (url === "/api/apps/sources") { | ||
| 96 | return jsonResponse({ sources: [demoSource] }); | ||
| 97 | } | ||
| 98 | if (url === "/api/apps/sources/demo-tools/sync") { | ||
| 99 | return jsonResponse({ | ||
| 100 | status: "up-to-date", | ||
| 101 | sourceRef: "abc123def456", | ||
| 102 | tools: ["repo-summary", "stale-issues"], | ||
| 103 | }); | ||
| 104 | } | ||
| 105 | if (url === "/api/apps/sources/demo-tools") { | ||
| 106 | return jsonResponse({ removed: true }); | ||
| 107 | } | ||
| 108 | if (url === "/api/apps/fs/dirs?path=%2FUsers%2Fada%2Ftools&includeHidden=true") { | ||
| 109 | return jsonResponse({ | ||
| 110 | path: "/Users/ada/tools", | ||
| 111 | parent: "/Users/ada", | ||
| 112 | dirs: [ | ||
| 113 | { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: true }, | ||
| 114 | ], | ||
| 115 | source: { toolFiles: ["greet.ts"], skipped: [], hasPackageJson: true }, | ||
| 116 | }); | ||
| 117 | } | ||
| 118 | return jsonResponse({ error: "not found" }, { status: 404 }); | ||
| 119 | }; | ||
| 120 | |||
| 121 | const created = await Effect.runPromise( | ||
| 122 | createCustomToolSourceEffect( | ||
| 123 | { | ||
| 124 | kind: "git", | ||
| 125 | slug: "demo-tools", | ||
| 126 | app: "demo-tools", | ||
| 127 | url: "https://gitlab.com/acme/demo-tools.git", | ||
| 128 | token: "ghp_secret", | ||
| 129 | }, | ||
| 130 | fetchImpl, | ||
| 131 | ), | ||
| 132 | ); | ||
| 133 | const listed = await listCustomToolSources(fetchImpl); | ||
| 134 | const synced = await Effect.runPromise(syncCustomToolSourceEffect("demo-tools", fetchImpl)); | ||
| 135 | const removed = await Effect.runPromise(removeCustomToolSourceEffect("demo-tools", fetchImpl)); | ||
| 136 | const dirs = await Effect.runPromise( | ||
| 137 | listCustomToolDirectoriesEffect({ path: "/Users/ada/tools", includeHidden: true }, fetchImpl), | ||
| 138 | ); | ||
| 139 | |||
| 140 | expect(created.source.slug).toBe("demo-tools"); | ||
| 141 | expect(createBody).toContain("ghp_secret"); | ||
| 142 | expect(JSON.stringify(listed)).not.toContain("ghp_secret"); | ||
| 143 | expect(syncStatusLabel(synced)).toBe("Already up to date."); | ||
| 144 | expect(removed).toEqual({ removed: true }); | ||
| 145 | expect(dirs.dirs).toEqual([ | ||
| 146 | { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: true }, | ||
| 147 | ]); | ||
| 148 | expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([ | ||
| 149 | "POST /api/apps/sources", | ||
| 150 | "GET /api/apps/sources", | ||
| 151 | "POST /api/apps/sources/demo-tools/sync", | ||
| 152 | "DELETE /api/apps/sources/demo-tools", | ||
| 153 | "GET /api/apps/fs/dirs?path=%2FUsers%2Fada%2Ftools&includeHidden=true", | ||
| 154 | ]); | ||
| 155 | }); | ||
| 156 | |||
| 157 | it("models the source panel and sync notice", () => { | ||
| 158 | const panel = sourcePanelModel(demoSource, { | ||
| 159 | now: Date.parse("2026-07-06T12:05:00.000Z"), | ||
| 160 | }); | ||
| 161 | const notice = syncNoticeFromResult( | ||
| 162 | { | ||
| 163 | status: "published", | ||
| 164 | sourceRef: "def456abc123", | ||
| 165 | tools: ["repo-summary", "stale-issues", "extra-tool"], | ||
| 166 | }, | ||
| 167 | demoSource.status.type === "published" ? demoSource.status.tools : [], | ||
| 168 | ); | ||
| 169 | |||
| 170 | expect(panel.title).toBe("demo-tools"); | ||
| 171 | expect(panel.source).toBe("Git repository: https://gitlab.com/acme/demo-tools.git @ main"); | ||
| 172 | expect(panel.status).toBe("Published 5m ago"); | ||
| 173 | expect(panel.sourceRef).toBe("abc123def456"); | ||
| 174 | expect(panel.tools).toEqual(["repo-summary", "stale-issues"]); | ||
| 175 | expect(notice.message).toBe("Published 3 tools."); | ||
| 176 | expect(notice.added).toEqual(["extra-tool"]); | ||
| 177 | expect(notice.sourceRef).toBe("def456abc123"); | ||
| 178 | }); | ||
| 179 | |||
| 180 | it("models the directory browser rows", () => { | ||
| 181 | expect( | ||
| 182 | directoryBrowserRows({ | ||
| 183 | path: "/Users/ada/tools", | ||
| 184 | parent: "/Users/ada", | ||
| 185 | dirs: [ | ||
| 186 | { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: false }, | ||
| 187 | { name: "linked", path: "/Users/ada/tools/linked", isSymlink: true, hasTools: false }, | ||
| 188 | ], | ||
| 189 | source: { toolFiles: [], skipped: [], hasPackageJson: false }, | ||
| 190 | }), | ||
| 191 | ).toEqual([ | ||
| 192 | { kind: "parent", name: "..", path: "/Users/ada" }, | ||
| 193 | { | ||
| 194 | kind: "dir", | ||
| 195 | name: "alpha", | ||
| 196 | path: "/Users/ada/tools/alpha", | ||
| 197 | isSymlink: false, | ||
| 198 | hasTools: false, | ||
| 199 | }, | ||
| 200 | { | ||
| 201 | kind: "dir", | ||
| 202 | name: "linked", | ||
| 203 | path: "/Users/ada/tools/linked", | ||
| 204 | isSymlink: true, | ||
| 205 | hasTools: false, | ||
| 206 | }, | ||
| 207 | ]); | ||
| 208 | }); | ||
| 209 | |||
| 210 | it("models directory source verdicts", () => { | ||
| 211 | expect( | ||
| 212 | directorySourceVerdict({ | ||
| 213 | path: "/repo", | ||
| 214 | parent: null, | ||
| 215 | dirs: [{ name: "tools", path: "/repo/tools", isSymlink: false, hasTools: false }], | ||
| 216 | source: { toolFiles: [], skipped: [], hasPackageJson: false }, | ||
| 217 | }), | ||
| 218 | ).toEqual({ | ||
| 219 | type: "empty-tools", | ||
| 220 | message: "tools/ has no tool files (tools/<name>.ts)", | ||
| 221 | }); | ||
| 222 | expect( | ||
| 223 | directorySourceVerdict({ | ||
| 224 | path: "/repo", | ||
| 225 | parent: null, | ||
| 226 | dirs: [], | ||
| 227 | source: { toolFiles: [], skipped: [], hasPackageJson: false }, | ||
| 228 | }), | ||
| 229 | ).toEqual({ | ||
| 230 | type: "missing-tools", | ||
| 231 | message: "No tools/ folder. Pick a folder containing tools/<name>.ts files.", | ||
| 232 | }); | ||
| 233 | expect( | ||
| 234 | directorySourceVerdict({ | ||
| 235 | path: "/repo", | ||
| 236 | parent: null, | ||
| 237 | dirs: [], | ||
| 238 | source: { | ||
| 239 | toolFiles: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"], | ||
| 240 | skipped: [], | ||
| 241 | hasPackageJson: true, | ||
| 242 | }, | ||
| 243 | }), | ||
| 244 | ).toEqual({ | ||
| 245 | type: "valid", | ||
| 246 | message: "6 tools found", | ||
| 247 | visibleTools: ["a", "b", "c", "d", "e"], | ||
| 248 | moreCount: 1, | ||
| 249 | }); | ||
| 250 | }); | ||
| 251 | |||
| 252 | it("formats failed sync diagnostics by stage", () => { | ||
| 253 | const errors = formatSyncErrors({ | ||
| 254 | status: "failed", | ||
| 255 | tools: [], | ||
| 256 | errors: [ | ||
| 257 | { | ||
| 258 | stage: "collect", | ||
| 259 | message: "schema export failed", | ||
| 260 | diagnostics: [{ path: "tools/x.ts", message: "unsupported schema" }], | ||
| 261 | }, | ||
| 262 | ], | ||
| 263 | }); | ||
| 264 | |||
| 265 | expect(errors).toEqual(["collect: schema export failed", "tools/x.ts: unsupported schema"]); | ||
| 266 | }); | ||
| 267 | }); | ||
| @@ -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,156 @@ | |||
| 1 | import type { | ||
| 2 | AppSourceRecord, | ||
| 3 | CustomToolsDirectoryListing, | ||
| 4 | SyncSourceResult, | ||
| 5 | } from "./custom-tools-client"; | ||
| 6 | import { | ||
| 7 | formatDiagnostics, | ||
| 8 | formatSyncErrors, | ||
| 9 | syncStatusLabel, | ||
| 10 | toolDiff, | ||
| 11 | } from "./custom-tools-client"; | ||
| 12 | |||
| 13 | export interface SourcePanelModel { | ||
| 14 | readonly title: string; | ||
| 15 | readonly source: string; | ||
| 16 | readonly sourceRef: string; | ||
| 17 | readonly status: string; | ||
| 18 | readonly tools: readonly string[]; | ||
| 19 | } | ||
| 20 | |||
| 21 | export interface SyncNoticeModel { | ||
| 22 | readonly status: SyncSourceResult["status"]; | ||
| 23 | readonly message: string; | ||
| 24 | readonly added: readonly string[]; | ||
| 25 | readonly removed: readonly string[]; | ||
| 26 | readonly errors: readonly string[]; | ||
| 27 | readonly sourceRef?: string; | ||
| 28 | } | ||
| 29 | |||
| 30 | export type DirectoryBrowserRow = | ||
| 31 | | { readonly kind: "parent"; readonly name: ".."; readonly path: string } | ||
| 32 | | { | ||
| 33 | readonly kind: "dir"; | ||
| 34 | readonly name: string; | ||
| 35 | readonly path: string; | ||
| 36 | readonly isSymlink: boolean; | ||
| 37 | readonly hasTools: boolean; | ||
| 38 | }; | ||
| 39 | |||
| 40 | export type DirectorySourceVerdict = | ||
| 41 | | { | ||
| 42 | readonly type: "valid"; | ||
| 43 | readonly message: string; | ||
| 44 | readonly visibleTools: readonly string[]; | ||
| 45 | readonly moreCount: number; | ||
| 46 | } | ||
| 47 | | { readonly type: "empty-tools"; readonly message: string } | ||
| 48 | | { readonly type: "missing-tools"; readonly message: string }; | ||
| 49 | |||
| 50 | const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced"); | ||
| 51 | |||
| 52 | export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => { | ||
| 53 | const diffMs = Math.max(0, now - timestamp); | ||
| 54 | const min = Math.floor(diffMs / 60_000); | ||
| 55 | if (min < 1) return "just now"; | ||
| 56 | if (min < 60) return `${min}m ago`; | ||
| 57 | const hrs = Math.floor(min / 60); | ||
| 58 | if (hrs < 24) return `${hrs}h ago`; | ||
| 59 | const days = Math.floor(hrs / 24); | ||
| 60 | if (days < 7) return `${days}d ago`; | ||
| 61 | return new Date(timestamp).toLocaleDateString(); | ||
| 62 | }; | ||
| 63 | |||
| 64 | export const toolsCountLabel = (count: number): string => | ||
| 65 | `${count} ${count === 1 ? "tool" : "tools"}`; | ||
| 66 | |||
| 67 | export const sourcePanelModel = ( | ||
| 68 | source: AppSourceRecord, | ||
| 69 | options?: { readonly now?: number }, | ||
| 70 | ): SourcePanelModel => { | ||
| 71 | const tools = | ||
| 72 | source.status.type === "published" || source.status.type === "up-to-date" | ||
| 73 | ? source.status.tools | ||
| 74 | : []; | ||
| 75 | const sourceLabel = source.config.kind === "git" ? source.config.url : source.config.path; | ||
| 76 | const pin = source.config.kind === "git" && source.config.ref ? ` @ ${source.config.ref}` : ""; | ||
| 77 | const status = | ||
| 78 | source.status.type === "pending" | ||
| 79 | ? "Pending sync" | ||
| 80 | : source.status.type === "failed" | ||
| 81 | ? `Failed ${formatRelativeSyncTime(source.status.at, options?.now)}` | ||
| 82 | : `${source.status.type === "published" ? "Published" : "Up to date"} ${formatRelativeSyncTime( | ||
| 83 | source.status.at, | ||
| 84 | options?.now, | ||
| 85 | )}`; | ||
| 86 | return { | ||
| 87 | title: source.app, | ||
| 88 | source: `${source.kind === "git" ? "Git repository" : "Local directory"}: ${sourceLabel}${pin}`, | ||
| 89 | sourceRef: shortRef(source.sourceRef), | ||
| 90 | status, | ||
| 91 | tools, | ||
| 92 | }; | ||
| 93 | }; | ||
| 94 | |||
| 95 | export const sourceFailureLines = (source: AppSourceRecord): readonly string[] => | ||
| 96 | source.status.type === "failed" ? formatDiagnostics(source.status.errors) : []; | ||
| 97 | |||
| 98 | export const syncNoticeFromResult = ( | ||
| 99 | result: SyncSourceResult, | ||
| 100 | beforeTools: readonly string[], | ||
| 101 | ): SyncNoticeModel => { | ||
| 102 | const diff = | ||
| 103 | result.status === "failed" ? { added: [], removed: [] } : toolDiff(beforeTools, result.tools); | ||
| 104 | return { | ||
| 105 | status: result.status, | ||
| 106 | message: syncStatusLabel(result), | ||
| 107 | added: diff.added, | ||
| 108 | removed: diff.removed, | ||
| 109 | errors: formatSyncErrors(result), | ||
| 110 | ...(result.sourceRef ? { sourceRef: result.sourceRef.slice(0, 12) } : {}), | ||
| 111 | }; | ||
| 112 | }; | ||
| 113 | |||
| 114 | export const directoryBrowserRows = ( | ||
| 115 | listing: CustomToolsDirectoryListing, | ||
| 116 | ): readonly DirectoryBrowserRow[] => [ | ||
| 117 | ...(listing.parent | ||
| 118 | ? [{ kind: "parent" as const, name: ".." as const, path: listing.parent }] | ||
| 119 | : []), | ||
| 120 | ...listing.dirs.map((dir) => ({ | ||
| 121 | kind: "dir" as const, | ||
| 122 | name: dir.name, | ||
| 123 | path: dir.path, | ||
| 124 | isSymlink: dir.isSymlink, | ||
| 125 | hasTools: dir.hasTools, | ||
| 126 | })), | ||
| 127 | ]; | ||
| 128 | |||
| 129 | const toolNameFromFile = (file: string): string => file.replace(/\.[^.]+$/, ""); | ||
| 130 | |||
| 131 | export const directorySourceVerdict = ( | ||
| 132 | listing: CustomToolsDirectoryListing, | ||
| 133 | ): DirectorySourceVerdict => { | ||
| 134 | const source = listing.source; | ||
| 135 | if (source.toolFiles.length > 0) { | ||
| 136 | const toolNames = source.toolFiles.map(toolNameFromFile); | ||
| 137 | const visibleTools = toolNames.slice(0, 5); | ||
| 138 | const moreCount = Math.max(0, toolNames.length - visibleTools.length); | ||
| 139 | return { | ||
| 140 | type: "valid", | ||
| 141 | message: `${toolsCountLabel(source.toolFiles.length)} found`, | ||
| 142 | visibleTools, | ||
| 143 | moreCount, | ||
| 144 | }; | ||
| 145 | } | ||
| 146 | if (listing.dirs.some((dir) => dir.name === "tools" && !dir.isSymlink)) { | ||
| 147 | return { | ||
| 148 | type: "empty-tools", | ||
| 149 | message: "tools/ has no tool files (tools/<name>.ts)", | ||
| 150 | }; | ||
| 151 | } | ||
| 152 | return { | ||
| 153 | type: "missing-tools", | ||
| 154 | message: "No tools/ folder. Pick a folder containing tools/<name>.ts files.", | ||
| 155 | }; | ||
| 156 | }; | ||
| @@ -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 | }; | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: tests use fixture server cleanup and hard-fail setup errors */ | 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: tests use fixture server cleanup and hard-fail setup errors */ |
| 2 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | 2 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; |
| 3 | import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; | 3 | import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; |
| 4 | import { tmpdir } from "node:os"; | 4 | import { homedir, tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; | 5 | import { join } from "node:path"; |
| 6 | import { deflateSync } from "node:zlib"; | 6 | import { deflateSync } from "node:zlib"; |
| 7 | 7 | ||
| @@ -14,7 +14,7 @@ import { parsePack, walkTree } from "../git-client/packfile"; · 6 unmodified lines | |||
| 14 | import { authForHost, checkRefs, uploadPack } from "../git-client/transport"; | 14 | import { authForHost, checkRefs, uploadPack } from "../git-client/transport"; |
| 15 | import { PUBLISH_LIMITS } from "../pipeline/publish"; | 15 | import { PUBLISH_LIMITS } from "../pipeline/publish"; |
| 16 | import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "./git-source"; | 16 | import { checkGitAppSourceRefs, fetchGitAppSource, parseGitSourceUrl } from "./git-source"; |
| 17 | import { fetchLocalDirectoryAppSource } from "./local-directory-source"; | 17 | import { fetchLocalDirectoryAppSource, listLocalDirectoryDirs } from "./local-directory-source"; |
| 18 | 18 | ||
| 19 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); | 19 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); |
| 20 | const textDecoder = new TextDecoder(); | 20 | const textDecoder = new TextDecoder(); |
| @@ -227,6 +227,7 @@ describe("git app sources", () => { · 206 unmodified lines | |||
| 227 | it.effect("rejects private git hosts under cloud posture", () => | 227 | it.effect("rejects private git hosts under cloud posture", () => |
| 228 | Effect.gen(function* () { | 228 | Effect.gen(function* () { |
| 229 | for (const url of [ | 229 | for (const url of [ |
| 230 | "http://localhost/repo.git", | ||
| 230 | "https://localhost/repo.git", | 231 | "https://localhost/repo.git", |
| 231 | "https://127.0.0.1/repo.git", | 232 | "https://127.0.0.1/repo.git", |
| 232 | "https://[::ffff:127.0.0.1]/repo.git", | 233 | "https://[::ffff:127.0.0.1]/repo.git", |
| @@ -239,6 +240,11 @@ describe("git app sources", () => { · 6 unmodified lines | |||
| 239 | const exit = yield* Effect.exit(parseGitSourceUrl(url)); | 240 | const exit = yield* Effect.exit(parseGitSourceUrl(url)); |
| 240 | expect(Exit.isFailure(exit)).toBe(true); | 241 | expect(Exit.isFailure(exit)).toBe(true); |
| 241 | } | 242 | } |
| 243 | expect( | ||
| 244 | yield* parseGitSourceUrl("http://127.0.0.1:9999/repo.git", { | ||
| 245 | allowPrivateHosts: true, | ||
| 246 | }), | ||
| 247 | ).toBeInstanceOf(URL); | ||
| 242 | expect(yield* parseGitSourceUrl("https://gitlab.com/acme/tools.git")).toBeInstanceOf(URL); | 248 | expect(yield* parseGitSourceUrl("https://gitlab.com/acme/tools.git")).toBeInstanceOf(URL); |
| 243 | }), | 249 | }), |
| 244 | ); | 250 | ); |
| @@ -461,6 +467,98 @@ describe("local-directory app sources", () => { · 216 unmodified lines | |||
| 461 | expect(Exit.isFailure(parent)).toBe(true); | 467 | expect(Exit.isFailure(parent)).toBe(true); |
| 462 | }); | 468 | }); |
| 463 | 469 | ||
| 470 | it("lists home by default and immediate subdirectories only", async () => { | ||
| 471 | const result = await run(listLocalDirectoryDirs({})); | ||
| 472 | expect(result.path).toBe(homedir()); | ||
| 473 | expect(result.dirs.every((entry) => entry.path.startsWith(result.path))).toBe(true); | ||
| 474 | }); | ||
| 475 | |||
| 476 | it("lists subdirectories, filters dotdirs, and can include hidden dirs", async () => { | ||
| 477 | const root = await mkdtemp(); | ||
| 478 | await mkdir(join(root, "alpha")); | ||
| 479 | await mkdir(join(root, ".hidden")); | ||
| 480 | await writeFile(join(root, "file.txt"), "not a directory"); | ||
| 481 | |||
| 482 | const visible = await run(listLocalDirectoryDirs({ path: root })); | ||
| 483 | const hidden = await run(listLocalDirectoryDirs({ path: root, includeHidden: true })); | ||
| 484 | |||
| 485 | expect(visible.dirs.map((entry) => entry.name)).toEqual(["alpha"]); | ||
| 486 | expect(hidden.dirs.map((entry) => entry.name)).toEqual([".hidden", "alpha"]); | ||
| 487 | }); | ||
| 488 | |||
| 489 | it("reports app source shape and subdirectories with tools folders", async () => { | ||
| 490 | const root = await mkdtemp(); | ||
| 491 | const valid = join(root, "valid-app"); | ||
| 492 | const emptyTools = join(root, "empty-tools"); | ||
| 493 | const noTools = join(root, "no-tools"); | ||
| 494 | const nested = join(root, "parent-with-tools"); | ||
| 495 | await mkdir(join(valid, "tools"), { recursive: true }); | ||
| 496 | await mkdir(join(valid, "workflows"), { recursive: true }); | ||
| 497 | await mkdir(join(valid, "ui"), { recursive: true }); | ||
| 498 | await mkdir(join(valid, "skills"), { recursive: true }); | ||
| 499 | await mkdir(join(emptyTools, "tools"), { recursive: true }); | ||
| 500 | await mkdir(noTools, { recursive: true }); | ||
| 501 | await mkdir(join(nested, "tools"), { recursive: true }); | ||
| 502 | await writeFile(join(valid, "package.json"), "{}"); | ||
| 503 | await writeFile(join(valid, "tools", "a.ts"), "export default {};"); | ||
| 504 | await writeFile(join(valid, "tools", "b.tsx"), "export default {};"); | ||
| 505 | await writeFile(join(valid, "tools", "bad_name.ts"), "export default {};"); | ||
| 506 | |||
| 507 | const parentListing = await run(listLocalDirectoryDirs({ path: root })); | ||
| 508 | const validListing = await run(listLocalDirectoryDirs({ path: valid })); | ||
| 509 | const emptyListing = await run(listLocalDirectoryDirs({ path: emptyTools })); | ||
| 510 | const noToolsListing = await run(listLocalDirectoryDirs({ path: noTools })); | ||
| 511 | |||
| 512 | expect(parentListing.dirs).toEqual( | ||
| 513 | expect.arrayContaining([ | ||
| 514 | expect.objectContaining({ name: "valid-app", hasTools: true }), | ||
| 515 | expect.objectContaining({ name: "empty-tools", hasTools: true }), | ||
| 516 | expect.objectContaining({ name: "no-tools", hasTools: false }), | ||
| 517 | expect.objectContaining({ name: "parent-with-tools", hasTools: true }), | ||
| 518 | ]), | ||
| 519 | ); | ||
| 520 | expect(validListing.source).toEqual({ | ||
| 521 | toolFiles: ["a.ts", "b.tsx"], | ||
| 522 | skipped: ["workflows", "ui", "skills"], | ||
| 523 | hasPackageJson: true, | ||
| 524 | }); | ||
| 525 | expect(emptyListing.source).toEqual({ | ||
| 526 | toolFiles: [], | ||
| 527 | skipped: [], | ||
| 528 | hasPackageJson: false, | ||
| 529 | }); | ||
| 530 | expect(noToolsListing.source).toEqual({ | ||
| 531 | toolFiles: [], | ||
| 532 | skipped: [], | ||
| 533 | hasPackageJson: false, | ||
| 534 | }); | ||
| 535 | }); | ||
| 536 | |||
| 537 | it("rejects parent traversal in directory listings", async () => { | ||
| 538 | const exit = await Effect.runPromiseExit(listLocalDirectoryDirs({ path: "/tmp/../bad" })); | ||
| 539 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 540 | }); | ||
| 541 | |||
| 542 | it("marks symlinked directories and does not descend through them", async () => { | ||
| 543 | const root = await mkdtemp(); | ||
| 544 | const target = await mkdtemp(); | ||
| 545 | await mkdir(join(target, "nested")); | ||
| 546 | await symlink(target, join(root, "linked")); | ||
| 547 | |||
| 548 | const listed = await run(listLocalDirectoryDirs({ path: root })); | ||
| 549 | expect(listed.dirs).toContainEqual({ | ||
| 550 | name: "linked", | ||
| 551 | path: join(root, "linked"), | ||
| 552 | isSymlink: true, | ||
| 553 | hasTools: false, | ||
| 554 | }); | ||
| 555 | |||
| 556 | const followed = await Effect.runPromiseExit( | ||
| 557 | listLocalDirectoryDirs({ path: join(root, "linked") }), | ||
| 558 | ); | ||
| 559 | expect(Exit.isFailure(followed)).toBe(true); | ||
| 560 | }); | ||
| 561 | |||
| 464 | it("does not read symlinks that escape the source root", async () => { | 562 | it("does not read symlinks that escape the source root", async () => { |
| 465 | const root = await mkdtemp(); | 563 | const root = await mkdtemp(); |
| 466 | const outside = await mkdtemp(); | 564 | const outside = await mkdtemp(); |
| @@ -1,11 +1,13 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-instanceof-tagged-error -- boundary: filesystem path validation and fs errors normalize to AppSourceError */ | 1 | /* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-instanceof-tagged-error -- boundary: filesystem path validation and fs errors normalize to AppSourceError */ |
| 2 | import { createHash } from "node:crypto"; | 2 | import { createHash } from "node:crypto"; |
| 3 | import { constants } from "node:fs"; | 3 | import { constants } from "node:fs"; |
| 4 | import { lstat, open, readdir, realpath } from "node:fs/promises"; | 4 | import { lstat, open, readdir, realpath, stat } from "node:fs/promises"; |
| 5 | import { isAbsolute, resolve, sep } from "node:path"; | 5 | import { homedir } from "node:os"; |
| 6 | import { dirname, isAbsolute, resolve, sep } from "node:path"; | ||
| 6 | 7 | ||
| 7 | import { Effect, Schema } from "effect"; | 8 | import { Effect, Schema } from "effect"; |
| 8 | 9 | ||
| 10 | import { RESERVED_ARTIFACT_DIRS, toolKeyFromPath } from "../pipeline/discover"; | ||
| 9 | import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish"; | 11 | import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish"; |
| 10 | import { AppSourceError, type AppSourceSnapshot } from "./app-source"; | 12 | import { AppSourceError, type AppSourceSnapshot } from "./app-source"; |
| 11 | import { | 13 | import { |
| @@ -23,10 +25,35 @@ export interface LocalDirectoryAppSourceSnapshot extends AppSourceSnapshot { · 11 unmodified lines | |||
| 23 | readonly skipped: readonly SourceSkippedFile[]; | 25 | readonly skipped: readonly SourceSkippedFile[]; |
| 24 | } | 26 | } |
| 25 | 27 | ||
| 28 | export interface LocalDirectoryDirsInput { | ||
| 29 | readonly path?: string; | ||
| 30 | readonly includeHidden?: boolean; | ||
| 31 | } | ||
| 32 | |||
| 33 | export interface LocalDirectoryDirEntry { | ||
| 34 | readonly name: string; | ||
| 35 | readonly path: string; | ||
| 36 | readonly isSymlink: boolean; | ||
| 37 | readonly hasTools: boolean; | ||
| 38 | } | ||
| 39 | |||
| 40 | export interface LocalDirectorySourceShape { | ||
| 41 | readonly toolFiles: readonly string[]; | ||
| 42 | readonly skipped: readonly string[]; | ||
| 43 | readonly hasPackageJson: boolean; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface LocalDirectoryDirsResult { | ||
| 47 | readonly path: string; | ||
| 48 | readonly parent: string | null; | ||
| 49 | readonly dirs: readonly LocalDirectoryDirEntry[]; | ||
| 50 | readonly source: LocalDirectorySourceShape; | ||
| 51 | } | ||
| 52 | |||
| 26 | const sha256 = (bytes: Uint8Array | string): string => | 53 | const sha256 = (bytes: Uint8Array | string): string => |
| 27 | createHash("sha256").update(bytes).digest("hex"); | 54 | createHash("sha256").update(bytes).digest("hex"); |
| 28 | 55 | ||
| 29 | const validateRoot = (path: string): Effect.Effect<string, AppSourceError> => | 56 | export const validateLocalDirectoryPath = (path: string): Effect.Effect<string, AppSourceError> => |
| 30 | Effect.try({ | 57 | Effect.try({ |
| 31 | try: () => { | 58 | try: () => { |
| 32 | if (!isAbsolute(path)) { | 59 | if (!isAbsolute(path)) { |
| @@ -175,11 +202,65 @@ const descriptionFor = ( · 142 unmodified lines | |||
| 175 | return typeof description === "string" ? description : undefined; | 202 | return typeof description === "string" ? description : undefined; |
| 176 | }); | 203 | }); |
| 177 | 204 | ||
| 205 | const lstatOrNull = (path: string): Effect.Effect<Awaited<ReturnType<typeof lstat>> | null> => | ||
| 206 | Effect.tryPromise({ | ||
| 207 | try: () => lstat(path), | ||
| 208 | catch: (cause) => | ||
| 209 | new AppSourceError({ message: "failed to stat local-directory path", path, cause }), | ||
| 210 | }).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 211 | |||
| 212 | const hasNonSymlinkDirectory = (path: string): Effect.Effect<boolean> => | ||
| 213 | Effect.gen(function* () { | ||
| 214 | const entry = yield* lstatOrNull(path); | ||
| 215 | return entry !== null && entry.isDirectory() && !entry.isSymbolicLink(); | ||
| 216 | }); | ||
| 217 | |||
| 218 | const hasNonSymlinkFile = (path: string): Effect.Effect<boolean> => | ||
| 219 | Effect.gen(function* () { | ||
| 220 | const entry = yield* lstatOrNull(path); | ||
| 221 | return entry !== null && entry.isFile() && !entry.isSymbolicLink(); | ||
| 222 | }); | ||
| 223 | |||
| 224 | const listLocalDirectorySourceShape = ( | ||
| 225 | root: string, | ||
| 226 | ): Effect.Effect<LocalDirectorySourceShape, AppSourceError> => | ||
| 227 | Effect.gen(function* () { | ||
| 228 | const toolsPath = `${root}${sep}tools`; | ||
| 229 | const toolsStat = yield* lstatOrNull(toolsPath); | ||
| 230 | const toolFiles: string[] = []; | ||
| 231 | if (toolsStat?.isDirectory() && !toolsStat.isSymbolicLink()) { | ||
| 232 | const toolEntries = yield* Effect.tryPromise({ | ||
| 233 | try: () => readdir(toolsPath, { withFileTypes: true }), | ||
| 234 | catch: (cause) => | ||
| 235 | new AppSourceError({ | ||
| 236 | message: "failed to read local-directory tools folder", | ||
| 237 | path: toolsPath, | ||
| 238 | cause, | ||
| 239 | }), | ||
| 240 | }); | ||
| 241 | for (const entry of toolEntries) { | ||
| 242 | if (!entry.isFile()) continue; | ||
| 243 | if (toolKeyFromPath(`tools/${entry.name}`) === null) continue; | ||
| 244 | toolFiles.push(entry.name); | ||
| 245 | } | ||
| 246 | toolFiles.sort((a, b) => a.localeCompare(b)); | ||
| 247 | } | ||
| 248 | const skipped: string[] = []; | ||
| 249 | for (const name of RESERVED_ARTIFACT_DIRS) { | ||
| 250 | if (yield* hasNonSymlinkDirectory(`${root}${sep}${name}`)) skipped.push(name); | ||
| 251 | } | ||
| 252 | return { | ||
| 253 | toolFiles: toolFiles.slice(0, 20), | ||
| 254 | skipped, | ||
| 255 | hasPackageJson: yield* hasNonSymlinkFile(`${root}${sep}package.json`), | ||
| 256 | }; | ||
| 257 | }); | ||
| 258 | |||
| 178 | export const fetchLocalDirectoryAppSource = ( | 259 | export const fetchLocalDirectoryAppSource = ( |
| 179 | input: LocalDirectoryAppSourceInput, | 260 | input: LocalDirectoryAppSourceInput, |
| 180 | ): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> => | 261 | ): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> => |
| 181 | Effect.gen(function* () { | 262 | Effect.gen(function* () { |
| 182 | const root = yield* validateRoot(input.path); | 263 | const root = yield* validateLocalDirectoryPath(input.path); |
| 183 | const rootReal = yield* Effect.tryPromise({ | 264 | const rootReal = yield* Effect.tryPromise({ |
| 184 | try: () => realpath(root), | 265 | try: () => realpath(root), |
| 185 | catch: (cause) => | 266 | catch: (cause) => |
| @@ -199,6 +280,83 @@ export const fetchLocalDirectoryAppSource = ( · 13 unmodified lines | |||
| 199 | }; | 280 | }; |
| 200 | }); | 281 | }); |
| 201 | 282 | ||
| 283 | export const listLocalDirectoryDirs = ( | ||
| 284 | input: LocalDirectoryDirsInput, | ||
| 285 | ): Effect.Effect<LocalDirectoryDirsResult, AppSourceError> => | ||
| 286 | Effect.gen(function* () { | ||
| 287 | const root = yield* validateLocalDirectoryPath(input.path?.trim() || homedir()); | ||
| 288 | const rootStat = yield* Effect.tryPromise({ | ||
| 289 | try: () => lstat(root), | ||
| 290 | catch: (cause) => | ||
| 291 | new AppSourceError({ | ||
| 292 | message: "failed to read local-directory source", | ||
| 293 | path: root, | ||
| 294 | cause, | ||
| 295 | }), | ||
| 296 | }); | ||
| 297 | if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { | ||
| 298 | return yield* new AppSourceError({ | ||
| 299 | message: "local-directory source path must be a directory", | ||
| 300 | path: root, | ||
| 301 | }); | ||
| 302 | } | ||
| 303 | const entries = yield* Effect.tryPromise({ | ||
| 304 | try: () => readdir(root), | ||
| 305 | catch: (cause) => | ||
| 306 | new AppSourceError({ | ||
| 307 | message: "failed to read local-directory source", | ||
| 308 | path: root, | ||
| 309 | cause, | ||
| 310 | }), | ||
| 311 | }); | ||
| 312 | const dirs: LocalDirectoryDirEntry[] = []; | ||
| 313 | const source = yield* listLocalDirectorySourceShape(root); | ||
| 314 | for (const name of entries) { | ||
| 315 | if (!input.includeHidden && name.startsWith(".")) continue; | ||
| 316 | const child = `${root}${sep}${name}`; | ||
| 317 | const entryStat = yield* Effect.tryPromise({ | ||
| 318 | try: () => lstat(child), | ||
| 319 | catch: (cause) => | ||
| 320 | new AppSourceError({ | ||
| 321 | message: "failed to stat local-directory child", | ||
| 322 | path: child, | ||
| 323 | cause, | ||
| 324 | }), | ||
| 325 | }).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 326 | if (!entryStat) continue; | ||
| 327 | if (entryStat.isDirectory()) { | ||
| 328 | dirs.push({ | ||
| 329 | name, | ||
| 330 | path: child, | ||
| 331 | isSymlink: false, | ||
| 332 | hasTools: yield* hasNonSymlinkDirectory(`${child}${sep}tools`), | ||
| 333 | }); | ||
| 334 | continue; | ||
| 335 | } | ||
| 336 | if (!entryStat.isSymbolicLink()) continue; | ||
| 337 | const targetStat = yield* Effect.tryPromise({ | ||
| 338 | try: () => stat(child), | ||
| 339 | catch: (cause) => | ||
| 340 | new AppSourceError({ | ||
| 341 | message: "failed to stat local-directory symlink target", | ||
| 342 | path: child, | ||
| 343 | cause, | ||
| 344 | }), | ||
| 345 | }).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 346 | if (targetStat?.isDirectory()) { | ||
| 347 | dirs.push({ name, path: child, isSymlink: true, hasTools: false }); | ||
| 348 | } | ||
| 349 | } | ||
| 350 | dirs.sort((a, b) => a.name.localeCompare(b.name)); | ||
| 351 | const parent = dirname(root); | ||
| 352 | return { | ||
| 353 | path: root, | ||
| 354 | parent: parent === root ? null : parent, | ||
| 355 | dirs, | ||
| 356 | source, | ||
| 357 | }; | ||
| 358 | }); | ||
| 359 | |||
| 202 | export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({ | 360 | export const makeLocalDirectoryAppSource = (input: LocalDirectoryAppSourceInput) => ({ |
| 203 | fetch: () => fetchLocalDirectoryAppSource(input), | 361 | fetch: () => fetchLocalDirectoryAppSource(input), |
| 204 | }); | 362 | }); |
| @@ -41,6 +41,103 @@ export const appToolExecutorConformance = ( · 40 unmodified lines | |||
| 41 | }), | 41 | }), |
| 42 | ); | 42 | ); |
| 43 | 43 | ||
| 44 | it.effect("collects Standard Schema input JSON schema extensions", () => | ||
| 45 | Effect.gen(function* () { | ||
| 46 | const bundled = yield* bundle(` | ||
| 47 | import { z } from "zod"; | ||
| 48 | import { defineTool } from "executor:app"; | ||
| 49 | export default defineTool({ | ||
| 50 | description: "Zod schema", | ||
| 51 | input: z.object({ name: z.string(), count: z.number().optional() }), | ||
| 52 | handler(input) { return input; }, | ||
| 53 | }); | ||
| 54 | `); | ||
| 55 | const collected = yield* makeExecutor().collect(bundled.code, { | ||
| 56 | fileSlug: "conformance", | ||
| 57 | sourcePath: "tools/conformance.ts", | ||
| 58 | }); | ||
| 59 | const inputSchema = collected.tools[0]?.inputSchema as { | ||
| 60 | readonly properties?: Record<string, { readonly type?: string }>; | ||
| 61 | readonly required?: readonly string[]; | ||
| 62 | }; | ||
| 63 | expect(inputSchema.properties?.name).toMatchObject({ type: "string" }); | ||
| 64 | expect(inputSchema.properties?.count).toBeDefined(); | ||
| 65 | expect(inputSchema.required ?? []).toContain("name"); | ||
| 66 | expect(inputSchema.required ?? []).not.toContain("count"); | ||
| 67 | expect(inputSchema).not.toHaveProperty("$schema"); | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | |||
| 71 | it.effect("rejects integration keys that collide with Standard Schema input fields", () => | ||
| 72 | Effect.gen(function* () { | ||
| 73 | const bundled = yield* bundle(` | ||
| 74 | import { z } from "zod"; | ||
| 75 | import { defineTool, integration } from "executor:app"; | ||
| 76 | export default defineTool({ | ||
| 77 | description: "Collision", | ||
| 78 | integrations: { crm: integration("dealcloud") }, | ||
| 79 | input: z.object({ crm: z.string() }), | ||
| 80 | handler(input) { return input; }, | ||
| 81 | }); | ||
| 82 | `); | ||
| 83 | const error = yield* makeExecutor() | ||
| 84 | .collect(bundled.code, { | ||
| 85 | fileSlug: "conformance", | ||
| 86 | sourcePath: "tools/conformance.ts", | ||
| 87 | }) | ||
| 88 | .pipe(Effect.flip); | ||
| 89 | expect(error).toMatchObject({ | ||
| 90 | kind: "collect", | ||
| 91 | message: expect.stringContaining('integration key "crm" collides with input field'), | ||
| 92 | diagnostics: [ | ||
| 93 | { | ||
| 94 | path: "tools/conformance.ts", | ||
| 95 | message: "integration key collides with input: crm", | ||
| 96 | }, | ||
| 97 | ], | ||
| 98 | }); | ||
| 99 | }), | ||
| 100 | ); | ||
| 101 | |||
| 102 | it.effect("rejects Standard Schema libraries without JSON schema extensions", () => | ||
| 103 | Effect.gen(function* () { | ||
| 104 | const bundled = yield* bundle(` | ||
| 105 | import { defineTool } from "executor:app"; | ||
| 106 | const input = { | ||
| 107 | "~standard": { | ||
| 108 | vendor: "fake", | ||
| 109 | version: 1, | ||
| 110 | validate(value) { return { value }; }, | ||
| 111 | }, | ||
| 112 | }; | ||
| 113 | export default defineTool({ | ||
| 114 | description: "Missing JSON schema extension", | ||
| 115 | input, | ||
| 116 | handler(value) { return value; }, | ||
| 117 | }); | ||
| 118 | `); | ||
| 119 | const error = yield* makeExecutor() | ||
| 120 | .collect(bundled.code, { | ||
| 121 | fileSlug: "conformance", | ||
| 122 | sourcePath: "tools/conformance.ts", | ||
| 123 | }) | ||
| 124 | .pipe(Effect.flip); | ||
| 125 | expect(error).toMatchObject({ | ||
| 126 | kind: "collect", | ||
| 127 | message: expect.stringContaining( | ||
| 128 | "schema library does not expose the Standard Schema jsonSchema extension", | ||
| 129 | ), | ||
| 130 | diagnostics: [ | ||
| 131 | { | ||
| 132 | path: "tools/conformance.ts", | ||
| 133 | message: | ||
| 134 | "input schema library does not expose the Standard Schema jsonSchema extension", | ||
| 135 | }, | ||
| 136 | ], | ||
| 137 | }); | ||
| 138 | }), | ||
| 139 | ); | ||
| 140 | |||
| 44 | it.effect("invokes handlers with split input and integrations", () => | 141 | it.effect("invokes handlers with split input and integrations", () => |
| 45 | Effect.gen(function* () { | 142 | Effect.gen(function* () { |
| 46 | const bundled = yield* bundle(` | 143 | const bundled = yield* bundle(` |
| @@ -9,6 +9,7 @@ · 8 unmodified lines | |||
| 9 | "types": ["bun-types", "node"], | 9 | "types": ["bun-types", "node"], |
| 10 | "noUnusedLocals": true, | 10 | "noUnusedLocals": true, |
| 11 | "noImplicitOverride": true, | 11 | "noImplicitOverride": true, |
| 12 | "jsx": "react-jsx", | ||
| 12 | "plugins": [ | 13 | "plugins": [ |
| 13 | { | 14 | { |
| 14 | "name": "@effect/language-service", | 15 | "name": "@effect/language-service", |
| @@ -18,5 +19,5 @@ · 3 unmodified lines | |||
| 18 | } | 19 | } |
| 19 | ] | 20 | ] |
| 20 | }, | 21 | }, |
| 21 | "include": ["src/**/*.ts"] | 22 | "include": ["src/**/*.ts", "src/**/*.tsx"] |
| 22 | } | 23 | } |
| @@ -95,6 +95,11 @@ const schemaDescription = (schema: JsonSchema): string | undefined => { · 94 unmodified lines | |||
| 95 | return undefined; | 95 | return undefined; |
| 96 | }; | 96 | }; |
| 97 | 97 | ||
| 98 | export const emptyEnumGuidance = (description: string | undefined): string => { | ||
| 99 | const integration = description?.match(/^Connection to use for .+ \(([^)]+)\)$/)?.[1]; | ||
| 100 | return integration ? `Connect ${integration} first.` : "No options available."; | ||
| 101 | }; | ||
| 102 | |||
| 98 | const DEFAULT_BY_TYPE: Record<string, () => unknown> = { | 103 | const DEFAULT_BY_TYPE: Record<string, () => unknown> = { |
| 99 | string: () => "", | 104 | string: () => "", |
| 100 | number: () => 0, | 105 | number: () => 0, |
| @@ -105,9 +110,10 @@ const DEFAULT_BY_TYPE: Record<string, () => unknown> = { · 4 unmodified lines | |||
| 105 | }; | 110 | }; |
| 106 | 111 | ||
| 107 | /** Seed used when the user clicks "Add" for an optional property or array item. | 112 | /** Seed used when the user clicks "Add" for an optional property or array item. |
| 108 | * Never auto-injected on load — only on explicit add. */ | 113 | * Schema defaults can also be displayed without being injected into JSON. */ |
| 109 | export const defaultForSchema = (schema: JsonSchema, root: JsonSchema): unknown => { | 114 | export const defaultForSchema = (schema: JsonSchema, root: JsonSchema): unknown => { |
| 110 | const s = deepResolve(schema, root); | 115 | const s = deepResolve(schema, root); |
| 116 | if (s.default !== undefined) return s.default; | ||
| 111 | if (s.const !== undefined) return s.const; | 117 | if (s.const !== undefined) return s.const; |
| 112 | if (Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0]; | 118 | if (Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0]; |
| 113 | const t = primaryType(s); | 119 | const t = primaryType(s); |
| @@ -223,8 +229,8 @@ export function JsonSchemaForm(props: { · 109 unmodified lines | |||
| 223 | } | 229 | } |
| 224 | 230 | ||
| 225 | // --------------------------------------------------------------------------- | 231 | // --------------------------------------------------------------------------- |
| 226 | // ObjectFields — renders an object's properties (required first, then present | 232 | // ObjectFields — renders an object's properties (required/default-backed first, |
| 227 | // optionals, then "Add" rows for absent optionals) | 233 | // then present optionals, then "Add" rows for absent optionals) |
| 228 | // --------------------------------------------------------------------------- | 234 | // --------------------------------------------------------------------------- |
| 229 | 235 | ||
| 230 | function ObjectFields(props: { | 236 | function ObjectFields(props: { |
| @@ -242,11 +248,15 @@ function ObjectFields(props: { · 11 unmodified lines | |||
| 242 | const requiredSet = new Set(schema.required ?? []); | 248 | const requiredSet = new Set(schema.required ?? []); |
| 243 | const entries = Object.entries(properties); | 249 | const entries = Object.entries(properties); |
| 244 | 250 | ||
| 245 | const present = entries.filter( | 251 | const present = entries.filter(([key, propSchema]: [string, JsonSchema]) => { |
| 246 | ([key]: [string, JsonSchema]) => key in value || requiredSet.has(key), | 252 | const resolved = deepResolve(propSchema, root); |
| 247 | ); | 253 | return key in value || requiredSet.has(key) || resolved.default !== undefined; |
| 254 | }); | ||
| 248 | const addable = entries.filter( | 255 | const addable = entries.filter( |
| 249 | ([key]: [string, JsonSchema]) => !requiredSet.has(key) && !(key in value), | 256 | ([key, propSchema]: [string, JsonSchema]) => |
| 257 | !requiredSet.has(key) && | ||
| 258 | !(key in value) && | ||
| 259 | deepResolve(propSchema, root).default === undefined, | ||
| 250 | ); | 260 | ); |
| 251 | 261 | ||
| 252 | // No properties → render nothing (no "takes no arguments" noise); a zero-arg | 262 | // No properties → render nothing (no "takes no arguments" noise); a zero-arg |
| @@ -316,6 +326,7 @@ function JsonSchemaField(props: { · 63 unmodified lines | |||
| 316 | const { root, name, fieldId, required, value, present, onChange, depth, disabled } = props; | 326 | const { root, name, fieldId, required, value, present, onChange, depth, disabled } = props; |
| 317 | const schema = deepResolve(props.schema, root); | 327 | const schema = deepResolve(props.schema, root); |
| 318 | const description = schemaDescription(schema); | 328 | const description = schemaDescription(schema); |
| 329 | const fieldValue = value === undefined && schema.default !== undefined ? schema.default : value; | ||
| 319 | 330 | ||
| 320 | const header = ( | 331 | const header = ( |
| 321 | <div className="flex items-center justify-between gap-2"> | 332 | <div className="flex items-center justify-between gap-2"> |
| @@ -353,7 +364,7 @@ function JsonSchemaField(props: { · 31 unmodified lines | |||
| 353 | schema={schema} | 364 | schema={schema} |
| 354 | root={root} | 365 | root={root} |
| 355 | fieldId={fieldId} | 366 | fieldId={fieldId} |
| 356 | value={value} | 367 | value={fieldValue} |
| 357 | onChange={onChange} | 368 | onChange={onChange} |
| 358 | depth={depth} | 369 | depth={depth} |
| 359 | disabled={disabled} | 370 | disabled={disabled} |
| @@ -407,6 +418,18 @@ function FieldControl(props: { · 47 unmodified lines | |||
| 407 | ); | 418 | ); |
| 408 | } | 419 | } |
| 409 | 420 | ||
| 421 | if (Array.isArray(schema.enum) && schema.enum.length === 0) { | ||
| 422 | const guidance = emptyEnumGuidance(schemaDescription(schema)); | ||
| 423 | return ( | ||
| 424 | <div className="space-y-1"> | ||
| 425 | <NativeSelect id={fieldId} className="w-full text-xs" value="" disabled={true}> | ||
| 426 | <NativeSelectOption value="">{guidance}</NativeSelectOption> | ||
| 427 | </NativeSelect> | ||
| 428 | <p className="text-[11px] leading-4 text-muted-foreground">{guidance}</p> | ||
| 429 | </div> | ||
| 430 | ); | ||
| 431 | } | ||
| 432 | |||
| 410 | // enum — native select. Optional/unset gets a leading empty option. | 433 | // enum — native select. Optional/unset gets a leading empty option. |
| 411 | if (Array.isArray(schema.enum) && schema.enum.length > 0) { | 434 | if (Array.isArray(schema.enum) && schema.enum.length > 0) { |
| 412 | const options = schema.enum; | 435 | const options = schema.enum; |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | type Owner, | 9 | type Owner, |
| 10 | } from "@executor-js/sdk/shared"; | 10 | } from "@executor-js/sdk/shared"; |
| 11 | 11 | ||
| 12 | import { toolProxyAddress } from "./tool-run-panel"; | 12 | import { shouldRenderConnectionPicker, toolProxyAddress } from "./tool-run-panel"; |
| 13 | 13 | ||
| 14 | const connection = (input: { readonly owner: Owner; readonly name: string }): Connection => ({ | 14 | const connection = (input: { readonly owner: Owner; readonly name: string }): Connection => ({ |
| 15 | owner: input.owner, | 15 | owner: input.owner, |
| @@ -22,6 +22,23 @@ const connection = (input: { readonly owner: Owner; readonly name: string }): Co · 6 unmodified lines | |||
| 22 | expiresAt: null, | 22 | expiresAt: null, |
| 23 | }); | 23 | }); |
| 24 | 24 | ||
| 25 | describe("shouldRenderConnectionPicker", () => { | ||
| 26 | it("hides the picker when there is only one connection", () => { | ||
| 27 | expect(shouldRenderConnectionPicker([connection({ owner: "org", name: "published" })])).toBe( | ||
| 28 | false, | ||
| 29 | ); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("keeps the picker when there are multiple connections", () => { | ||
| 33 | expect( | ||
| 34 | shouldRenderConnectionPicker([ | ||
| 35 | connection({ owner: "org", name: "prod" }), | ||
| 36 | connection({ owner: "user", name: "scratch" }), | ||
| 37 | ]), | ||
| 38 | ).toBe(true); | ||
| 39 | }); | ||
| 40 | }); | ||
| 41 | |||
| 25 | describe("toolProxyAddress", () => { | 42 | describe("toolProxyAddress", () => { |
| 26 | it("builds the address under the SELECTED connection's own owner — user", () => { | 43 | it("builds the address under the SELECTED connection's own owner — user", () => { |
| 27 | expect( | 44 | expect( |
| @@ -54,6 +54,9 @@ export const toolProxyAddress = (input: { · 53 unmodified lines | |||
| 54 | }): string => | 54 | }): string => |
| 55 | `${input.integration}.${input.connection.owner}.${input.connection.name}.${input.toolName}`; | 55 | `${input.integration}.${input.connection.owner}.${input.connection.name}.${input.toolName}`; |
| 56 | 56 | ||
| 57 | export const shouldRenderConnectionPicker = (connections: readonly Connection[]): boolean => | ||
| 58 | connections.length > 1; | ||
| 59 | |||
| 57 | type RunResult = | 60 | type RunResult = |
| 58 | | { | 61 | | { |
| 59 | readonly kind: "completed"; | 62 | readonly kind: "completed"; |
| @@ -261,33 +264,35 @@ export function ToolRunPanel(props: { · 201 unmodified lines | |||
| 261 | if (connections.length === 0) { | 264 | if (connections.length === 0) { |
| 262 | return <p className="text-sm text-muted-foreground">Add a connection to test tools.</p>; | 265 | return <p className="text-sm text-muted-foreground">Add a connection to test tools.</p>; |
| 263 | } | 266 | } |
| 267 | const renderConnectionPicker = shouldRenderConnectionPicker(connections); | ||
| 264 | 268 | ||
| 265 | return ( | 269 | return ( |
| 266 | <div className="space-y-4"> | 270 | <div className="space-y-4"> |
| 267 | <CardStack> | 271 | <CardStack> |
| 268 | <CardStackContent className="space-y-4 px-4 py-4"> | 272 | <CardStackContent className="space-y-4 px-4 py-4"> |
| 269 | {/* Connection picker — which account to run against. */} | 273 | {renderConnectionPicker && ( |
| 270 | <div className="space-y-1.5"> | 274 | <div className="space-y-1.5"> |
| 271 | <Label htmlFor="tool-run-connection">Connection</Label> | 275 | <Label htmlFor="tool-run-connection">Connection</Label> |
| 272 | <NativeSelect | 276 | <NativeSelect |
| 273 | id="tool-run-connection" | 277 | id="tool-run-connection" |
| 274 | className="w-full" | 278 | className="w-full" |
| 275 | value={selectedConnection ?? ""} | 279 | value={selectedConnection ?? ""} |
| 276 | onChange={(e: React.ChangeEvent<HTMLSelectElement>) => | 280 | onChange={(e: React.ChangeEvent<HTMLSelectElement>) => |
| 277 | setSelectedConnection(e.target.value as ConnectionName) | 281 | setSelectedConnection(e.target.value as ConnectionName) |
| 278 | } | 282 | } |
| 279 | disabled={running} | 283 | disabled={running} |
| 280 | > | 284 | > |
| 281 | {connections.map((connection: Connection) => ( | 285 | {connections.map((connection: Connection) => ( |
| 282 | <NativeSelectOption key={connection.name} value={connection.name}> | 286 | <NativeSelectOption key={connection.name} value={connection.name}> |
| 283 | {connection.identityLabel || connection.name} | 287 | {connection.identityLabel || connection.name} |
| 284 | </NativeSelectOption> | 288 | </NativeSelectOption> |
| 285 | ))} | 289 | ))} |
| 286 | </NativeSelect> | 290 | </NativeSelect> |
| 287 | <p className="text-xs text-muted-foreground"> | 291 | <p className="text-xs text-muted-foreground"> |
| 288 | Pick the account whose credentials you want to verify. | 292 | Pick the account whose credentials you want to verify. |
| 289 | </p> | 293 | </p> |
| 290 | </div> | 294 | </div> |
| 295 | )} | ||
| 291 | 296 | ||
| 292 | {/* Arguments editor — dynamic form built from the input JSON Schema, | 297 | {/* Arguments editor — dynamic form built from the input JSON Schema, |
| 293 | with a Raw JSON escape hatch. Both views share the `argsJson` | 298 | with a Raw JSON escape hatch. Both views share the `argsJson` |
| @@ -0,0 +1,16 @@ | |||
| 1 | export type IntegrationDetailSearchTab = "accounts" | "source" | "tools"; | ||
| 2 | export type IntegrationDetailInternalTab = "accounts" | "tools"; | ||
| 3 | |||
| 4 | export const integrationDetailTabForAddCompletion = ( | ||
| 5 | pluginKey: string, | ||
| 6 | ): IntegrationDetailSearchTab | undefined => (pluginKey === "apps" ? "source" : undefined); | ||
| 7 | |||
| 8 | export const integrationDetailInternalTabFromSearch = ( | ||
| 9 | tab: IntegrationDetailSearchTab | undefined, | ||
| 10 | ): IntegrationDetailInternalTab => (tab === "tools" ? "tools" : "accounts"); | ||
| 11 | |||
| 12 | export const integrationDetailSearchTabForInternal = ( | ||
| 13 | integrationKind: string | undefined, | ||
| 14 | tab: IntegrationDetailInternalTab, | ||
| 15 | ): IntegrationDetailSearchTab => | ||
| 16 | tab === "tools" ? "tools" : integrationKind === "apps" ? "source" : "accounts"; | ||
| @@ -1,8 +1,11 @@ | |||
| 1 | import { Suspense } from "react"; | 1 | import { Suspense } from "react"; |
| 2 | import { useAtomRefresh } from "@effect/atom-react"; | ||
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 3 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | 4 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 5 | import { integrationsOptimisticAtom } from "../api/atoms"; | ||
| 4 | import { trackEvent } from "../api/analytics"; | 6 | import { trackEvent } from "../api/analytics"; |
| 5 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 7 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 8 | import { integrationDetailTabForAddCompletion } from "../lib/integration-detail-tabs"; | ||
| 6 | 9 | ||
| 7 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 8 | // Page | 11 | // Page |
| @@ -18,6 +21,7 @@ export function AddIntegrationPage(props: { · 9 unmodified lines | |||
| 18 | const { pluginKey, url, preset, namespace } = props; | 21 | const { pluginKey, url, preset, namespace } = props; |
| 19 | const navigate = useNavigate(); | 22 | const navigate = useNavigate(); |
| 20 | const integrationPlugins = useIntegrationPlugins(); | 23 | const integrationPlugins = useIntegrationPlugins(); |
| 24 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); | ||
| 21 | 25 | ||
| 22 | const plugin = integrationPlugins.find((p) => p.key === pluginKey); | 26 | const plugin = integrationPlugins.find((p) => p.key === pluginKey); |
| 23 | 27 | ||
| @@ -59,9 +63,15 @@ export function AddIntegrationPage(props: { · 35 unmodified lines | |||
| 59 | plugin_key: pluginKey, | 63 | plugin_key: pluginKey, |
| 60 | ...(slug ? { integration_slug: slug } : {}), | 64 | ...(slug ? { integration_slug: slug } : {}), |
| 61 | }); | 65 | }); |
| 66 | refreshIntegrations(); | ||
| 67 | const tab = integrationDetailTabForAddCompletion(pluginKey); | ||
| 62 | void navigate( | 68 | void navigate( |
| 63 | slug | 69 | slug |
| 64 | ? { to: "/{-$orgSlug}/integrations/$namespace", params: { namespace: slug } } | 70 | ? { |
| 71 | to: "/{-$orgSlug}/integrations/$namespace", | ||
| 72 | params: { namespace: slug }, | ||
| 73 | search: tab ? { tab } : {}, | ||
| 74 | } | ||
| 65 | : { to: "/{-$orgSlug}" }, | 75 | : { to: "/{-$orgSlug}" }, |
| 66 | ); | 76 | ); |
| 67 | }} | 77 | }} |
| @@ -36,6 +36,12 @@ import { Skeleton } from "../components/skeleton"; · 35 unmodified lines | |||
| 36 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 36 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 37 | import { ErrorState } from "../components/error-state"; | 37 | import { ErrorState } from "../components/error-state"; |
| 38 | import { isAsyncResultLoading } from "../lib/async-result"; | 38 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 39 | import { | ||
| 40 | integrationDetailInternalTabFromSearch, | ||
| 41 | integrationDetailSearchTabForInternal, | ||
| 42 | type IntegrationDetailInternalTab, | ||
| 43 | type IntegrationDetailSearchTab, | ||
| 44 | } from "../lib/integration-detail-tabs"; | ||
| 39 | 45 | ||
| 40 | // v2: the route's `namespace` param is the integration slug. Tools belong to | 46 | // v2: the route's `namespace` param is the integration slug. Tools belong to |
| 41 | // the integration's per-owner connections; a tool's policy id is | 47 | // the integration's per-owner connections; a tool's policy id is |
| @@ -52,7 +58,10 @@ type ToolRow = { · 10 unmodified lines | |||
| 52 | readonly static?: boolean; | 58 | readonly static?: boolean; |
| 53 | }; | 59 | }; |
| 54 | 60 | ||
| 55 | export function IntegrationDetailPage(props: { namespace: string }) { | 61 | export function IntegrationDetailPage(props: { |
| 62 | namespace: string; | ||
| 63 | tab?: IntegrationDetailSearchTab; | ||
| 64 | }) { | ||
| 56 | const { namespace } = props; | 65 | const { namespace } = props; |
| 57 | const slug = IntegrationSlug.make(namespace); | 66 | const slug = IntegrationSlug.make(namespace); |
| 58 | const integrationPlugins = useIntegrationPlugins(); | 67 | const integrationPlugins = useIntegrationPlugins(); |
| @@ -89,7 +98,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 30 unmodified lines | |||
| 89 | const [deleting, setDeleting] = useState(false); | 98 | const [deleting, setDeleting] = useState(false); |
| 90 | const [refreshing, setRefreshing] = useState(false); | 99 | const [refreshing, setRefreshing] = useState(false); |
| 91 | const [editSheetOpen, setEditSheetOpen] = useState(false); | 100 | const [editSheetOpen, setEditSheetOpen] = useState(false); |
| 92 | const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts"); | 101 | const [activeTab, setActiveTab] = useState<IntegrationDetailInternalTab>(() => |
| 102 | integrationDetailInternalTabFromSearch(props.tab), | ||
| 103 | ); | ||
| 93 | const [manualAccountHandoff, setManualAccountHandoff] = | 104 | const [manualAccountHandoff, setManualAccountHandoff] = |
| 94 | useState<IntegrationAccountHandoff | null>(null); | 105 | useState<IntegrationAccountHandoff | null>(null); |
| 95 | const [locationSearch] = useState(() => | 106 | const [locationSearch] = useState(() => |
| @@ -101,9 +112,14 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 5 unmodified lines | |||
| 101 | setEditSheetOpen(false); | 112 | setEditSheetOpen(false); |
| 102 | }, [namespace]); | 113 | }, [namespace]); |
| 103 | 114 | ||
| 115 | useEffect(() => { | ||
| 116 | setActiveTab(integrationDetailInternalTabFromSearch(props.tab)); | ||
| 117 | }, [namespace, props.tab]); | ||
| 118 | |||
| 104 | const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null; | 119 | const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null; |
| 105 | useExecutorDocumentTitle(integrationData?.name || namespace); | 120 | useExecutorDocumentTitle(integrationData?.name || namespace); |
| 106 | const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in"; | 121 | const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in"; |
| 122 | const isAppsIntegration = integrationData?.kind === "apps"; | ||
| 107 | const currentTab = isBuiltInIntegration ? "tools" : activeTab; | 123 | const currentTab = isBuiltInIntegration ? "tools" : activeTab; |
| 108 | const canRefresh = integrationData?.canRefresh ?? false; | 124 | const canRefresh = integrationData?.canRefresh ?? false; |
| 109 | const canRemove = integrationData?.canRemove ?? false; | 125 | const canRemove = integrationData?.canRemove ?? false; |
| @@ -153,6 +169,9 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 43 unmodified lines | |||
| 153 | // Find the plugin edit component based on integration kind | 169 | // Find the plugin edit component based on integration kind |
| 154 | const editPlugin = useMemo(() => { | 170 | const editPlugin = useMemo(() => { |
| 155 | if (!integrationData) return null; | 171 | if (!integrationData) return null; |
| 172 | if (integrationData.kind === "apps") { | ||
| 173 | return integrationPlugins.find((p) => p.key === "apps") ?? null; | ||
| 174 | } | ||
| 156 | return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null; | 175 | return integrationPlugins.find((p) => p.key === integrationData.kind) ?? null; |
| 157 | }, [integrationData, integrationPlugins]); | 176 | }, [integrationData, integrationPlugins]); |
| 158 | 177 | ||
| @@ -347,6 +366,18 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 188 unmodified lines | |||
| 347 | setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` }); | 366 | setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` }); |
| 348 | }; | 367 | }; |
| 349 | 368 | ||
| 369 | const handleTabChange = (value: string) => { | ||
| 370 | const nextTab = value === "tools" ? "tools" : "accounts"; | ||
| 371 | setActiveTab(nextTab); | ||
| 372 | void navigate({ | ||
| 373 | to: "/{-$orgSlug}/integrations/$namespace", | ||
| 374 | params: { namespace }, | ||
| 375 | search: { | ||
| 376 | tab: integrationDetailSearchTabForInternal(integrationData?.kind, nextTab), | ||
| 377 | }, | ||
| 378 | }); | ||
| 379 | }; | ||
| 380 | |||
| 350 | return ( | 381 | return ( |
| 351 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | 382 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> |
| 352 | {/* Header bar */} | 383 | {/* Header bar */} |
| @@ -363,7 +394,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 10 unmodified lines | |||
| 363 | </div> | 394 | </div> |
| 364 | 395 | ||
| 365 | <div className="flex shrink-0 items-center gap-2"> | 396 | <div className="flex shrink-0 items-center gap-2"> |
| 366 | {!confirmDelete && !isBuiltInIntegration && integrationData && ( | 397 | {!confirmDelete && !isBuiltInIntegration && !isAppsIntegration && integrationData && ( |
| 367 | <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}> | 398 | <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}> |
| 368 | Edit | 399 | Edit |
| 369 | </Button> | 400 | </Button> |
| @@ -381,6 +412,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 11 unmodified lines | |||
| 381 | )} | 412 | )} |
| 382 | 413 | ||
| 383 | {canRemove && | 414 | {canRemove && |
| 415 | !isAppsIntegration && | ||
| 384 | (confirmDelete ? ( | 416 | (confirmDelete ? ( |
| 385 | <div className="flex items-center gap-2"> | 417 | <div className="flex items-center gap-2"> |
| 386 | <Button | 418 | <Button |
| @@ -415,12 +447,16 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 28 unmodified lines | |||
| 415 | 447 | ||
| 416 | <Tabs | 448 | <Tabs |
| 417 | value={currentTab} | 449 | value={currentTab} |
| 418 | onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")} | 450 | onValueChange={handleTabChange} |
| 419 | className="min-h-0 flex-1 gap-0 overflow-hidden" | 451 | className="min-h-0 flex-1 gap-0 overflow-hidden" |
| 420 | > | 452 | > |
| 421 | <div className="shrink-0 border-b border-border/60 px-4 py-2"> | 453 | <div className="shrink-0 border-b border-border/60 px-4 py-2"> |
| 422 | <TabsList variant="line"> | 454 | <TabsList variant="line"> |
| 423 | {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>} | 455 | {!isBuiltInIntegration && ( |
| 456 | <TabsTrigger value="accounts"> | ||
| 457 | {isAppsIntegration ? "Source" : "Accounts"} | ||
| 458 | </TabsTrigger> | ||
| 459 | )} | ||
| 424 | <TabsTrigger value="tools">Tools</TabsTrigger> | 460 | <TabsTrigger value="tools">Tools</TabsTrigger> |
| 425 | </TabsList> | 461 | </TabsList> |
| 426 | </div> | 462 | </div> |
| @@ -49,6 +49,7 @@ import { ErrorState } from "../components/error-state"; · 48 unmodified lines | |||
| 49 | import { isAsyncResultLoading } from "../lib/async-result"; | 49 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 50 | 50 | ||
| 51 | const KIND_TO_PLUGIN_KEY: Record<string, string> = { | 51 | const KIND_TO_PLUGIN_KEY: Record<string, string> = { |
| 52 | apps: "apps", | ||
| 52 | openapi: "openapi", | 53 | openapi: "openapi", |
| 53 | mcp: "mcp", | 54 | mcp: "mcp", |
| 54 | graphql: "graphql", | 55 | graphql: "graphql", |
| @@ -66,6 +67,41 @@ const bestDetection = ( · 11 unmodified lines | |||
| 66 | ): IntegrationDetectionResult | undefined => | 67 | ): IntegrationDetectionResult | undefined => |
| 67 | [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; | 68 | [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; |
| 68 | 69 | ||
| 70 | // Hosts where a two-segment path (owner/repo) is unambiguously a git remote. | ||
| 71 | const GIT_REPO_HOSTS = new Set(["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"]); | ||
| 72 | |||
| 73 | // Deliberately conservative: only claim URLs that are unambiguously git | ||
| 74 | // remotes (a `.git` suffix, or owner/repo on a known git host). Anything else | ||
| 75 | // falls through to the server-side detection so OpenAPI/MCP/GraphQL URLs keep | ||
| 76 | // their existing flows. | ||
| 77 | const detectGitRepository = ( | ||
| 78 | raw: string, | ||
| 79 | ): { readonly endpoint: string; readonly slug: string } | null => { | ||
| 80 | const value = raw.trim(); | ||
| 81 | if (!URL.canParse(value)) return null; | ||
| 82 | const parsed = new URL(value); | ||
| 83 | if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; | ||
| 84 | if (parsed.username || parsed.password) return null; | ||
| 85 | const segments = parsed.pathname.split("/").filter(Boolean); | ||
| 86 | const knownGitHost = | ||
| 87 | GIT_REPO_HOSTS.has(parsed.hostname.toLowerCase()) && | ||
| 88 | segments.length === 2 && | ||
| 89 | parsed.search === ""; | ||
| 90 | if (!parsed.pathname.endsWith(".git") && !knownGitHost) return null; | ||
| 91 | const name = segments.at(-1)?.replace(/\.git$/, "") ?? ""; | ||
| 92 | if (!name) return null; | ||
| 93 | parsed.hash = ""; | ||
| 94 | return { | ||
| 95 | endpoint: parsed.toString(), | ||
| 96 | slug: name | ||
| 97 | .trim() | ||
| 98 | .toLowerCase() | ||
| 99 | .replace(/[^a-z0-9-]+/g, "-") | ||
| 100 | .replace(/^-+|-+$/g, "") | ||
| 101 | .slice(0, 80), | ||
| 102 | }; | ||
| 103 | }; | ||
| 104 | |||
| 69 | // --------------------------------------------------------------------------- | 105 | // --------------------------------------------------------------------------- |
| 70 | // Page | 106 | // Page |
| 71 | // --------------------------------------------------------------------------- | 107 | // --------------------------------------------------------------------------- |
| @@ -176,6 +212,22 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 104 unmodified lines | |||
| 176 | if (!trimmed) return; | 212 | if (!trimmed) return; |
| 177 | setDetecting(true); | 213 | setDetecting(true); |
| 178 | setError(null); | 214 | setError(null); |
| 215 | const gitRepository = detectGitRepository(trimmed); | ||
| 216 | if (gitRepository && integrationPlugins.some((p) => p.key === "apps")) { | ||
| 217 | trackEvent("integration_detect_submitted", { | ||
| 218 | success: true, | ||
| 219 | detected_kind: "apps", | ||
| 220 | confidence: "high", | ||
| 221 | }); | ||
| 222 | trackEvent("integration_add_started", { plugin_key: "apps", via: "detect" }); | ||
| 223 | closeAndReset(); | ||
| 224 | void navigate({ | ||
| 225 | to: "/{-$orgSlug}/integrations/add/$pluginKey", | ||
| 226 | params: { pluginKey: "apps" }, | ||
| 227 | search: { url: gitRepository.endpoint, namespace: gitRepository.slug }, | ||
| 228 | }); | ||
| 229 | return; | ||
| 230 | } | ||
| 179 | // Detection is read-only — it inspects a URL and returns candidates without | 231 | // Detection is read-only — it inspects a URL and returns candidates without |
| 180 | // mutating the catalog, so it invalidates nothing. | 232 | // mutating the catalog, so it invalidates nothing. |
| 181 | const exit = await doDetect({ | 233 | const exit = await doDetect({ |
| @@ -1,10 +1,25 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 2 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 3 | ||
| 3 | import { IntegrationDetailPage } from "../pages/integration-detail"; | 4 | import { IntegrationDetailPage } from "../pages/integration-detail"; |
| 5 | import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs"; | ||
| 6 | |||
| 7 | const SearchParams = Schema.toStandardSchemaV1( | ||
| 8 | Schema.Struct({ | ||
| 9 | tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])), | ||
| 10 | }), | ||
| 11 | ); | ||
| 4 | 12 | ||
| 5 | export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({ | 13 | export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({ |
| 14 | validateSearch: SearchParams, | ||
| 6 | component: () => { | 15 | component: () => { |
| 7 | const { namespace } = Route.useParams(); | 16 | const { namespace } = Route.useParams(); |
| 8 | return <IntegrationDetailPage namespace={namespace} />; | 17 | const { tab } = Route.useSearch(); |
| 18 | return ( | ||
| 19 | <IntegrationDetailPage | ||
| 20 | namespace={namespace} | ||
| 21 | tab={tab as IntegrationDetailSearchTab | undefined} | ||
| 22 | /> | ||
| 23 | ); | ||
| 9 | }, | 24 | }, |
| 10 | }); | 25 | }); |