Unified · Split
| @@ -40,6 +40,7 @@ · 39 unmodified lines | ||
| 40 | 40 | |
| 41 | 41 | // Monorepo-specific rules. |
| 42 | 42 | "executor-monorepo/no-cross-workspace-relative-imports": "error", |
| 43 | + "executor-monorepo/no-node-fs-with-effect-imports": "error", | |
| 43 | 44 | "executor-monorepo/no-raw-effect-fail-errors": "error", |
| 44 | 45 | "executor-monorepo/no-yield-effect-fail": "error", |
| 45 | 46 | "executor-monorepo/no-workspace-src-imports": "warn" |
| @@ -1,9 +1,7 @@ | ||
| 1 | −import { existsSync, readFileSync } from "node:fs"; | |
| 2 | −import { mkdir, open, readFile, rm } from "node:fs/promises"; | |
| 3 | 1 | import { spawn } from "node:child_process"; |
| 4 | −import { dirname, resolve } from "node:path"; | |
| 2 | +import { createRequire } from "node:module"; | |
| 3 | +import { dirname } from "node:path"; | |
| 5 | 4 | import { createInterface } from "node:readline/promises"; |
| 6 | −import { fileURLToPath } from "node:url"; | |
| 7 | 5 | import { FileSystem } from "@effect/platform"; |
| 8 | 6 | import { Args, Command, Options } from "@effect/cli"; |
| 9 | 7 | import { |
| @@ -120,17 +118,17 @@ const readCode = (input: { · 110 unmodified lines | ||
| 120 | 118 | code?: string; |
| 121 | 119 | file?: string; |
| 122 | 120 | stdin?: boolean; |
| 123 | −}): Effect.Effect<string, Error, never> => | |
| 121 | +}): Effect.Effect<string, Error, FileSystem.FileSystem> => | |
| 124 | 122 | Effect.gen(function* () { |
| 125 | 123 | if (input.code && input.code.trim().length > 0) { |
| 126 | 124 | return input.code; |
| 127 | 125 | } |
| 128 | 126 | |
| 129 | 127 | if (input.file && input.file.trim().length > 0) { |
| 130 | − const contents = yield* Effect.tryPromise({ | |
| 131 | − try: () => readFile(input.file!, "utf8"), | |
| 132 | − catch: toError, | |
| 133 | − }); | |
| 128 | + const fs = yield* FileSystem.FileSystem; | |
| 129 | + const contents = yield* fs.readFileString(input.file!, "utf8").pipe( | |
| 130 | + Effect.mapError(toError), | |
| 131 | + ); | |
| 134 | 132 | if (contents.trim().length > 0) { |
| 135 | 133 | return contents; |
| 136 | 134 | } |
| @@ -151,22 +149,17 @@ const getBootstrapClient = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 14 unmodified lines | ||
| 151 | 149 | createControlPlaneClient({ baseUrl }); |
| 152 | 150 | |
| 153 | 151 | const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema); |
| 154 | −const cliSourceDir = dirname(fileURLToPath(import.meta.url)); | |
| 152 | +const require = createRequire(import.meta.url); | |
| 155 | 153 | const CLI_NAME = "executor"; |
| 156 | 154 | const CLI_VERSION = (() => { |
| 157 | 155 | const candidatePaths = [ |
| 158 | − resolve(cliSourceDir, "../package.json"), | |
| 159 | − resolve(cliSourceDir, "../../package.json"), | |
| 156 | + "../package.json", | |
| 157 | + "../../package.json", | |
| 160 | 158 | ]; |
| 161 | 159 | |
| 162 | 160 | for (const candidatePath of candidatePaths) { |
| 163 | − if (!existsSync(candidatePath)) { | |
| 164 | − continue; | |
| 165 | − } | |
| 166 | − | |
| 167 | 161 | try { |
| 168 | − const contents = readFileSync(candidatePath, "utf8"); | |
| 169 | − const metadata = JSON.parse(contents) as { version?: string }; | |
| 162 | + const metadata = require(candidatePath) as { version?: string }; | |
| 170 | 163 | if (metadata.version && metadata.version.trim().length > 0) { |
| 171 | 164 | return metadata.version; |
| 172 | 165 | } |
| @@ -428,24 +421,30 @@ const getDefaultServerOptions = (port: number = DEFAULT_SERVER_PORT) => { · 255 unmodified lines | ||
| 428 | 421 | }; |
| 429 | 422 | |
| 430 | 423 | const startServerInBackground = (port: number) => |
| 431 | − Effect.tryPromise({ | |
| 432 | − try: async () => { | |
| 424 | + Effect.scoped( | |
| 425 | + Effect.gen(function* () { | |
| 426 | + const fs = yield* FileSystem.FileSystem; | |
| 433 | 427 | const command = resolveSelfCommand(["__local-server", "--port", String(port)]); |
| 434 | − await mkdir(dirname(DEFAULT_SERVER_LOG_FILE), { recursive: true }); | |
| 435 | − const logHandle = await open(DEFAULT_SERVER_LOG_FILE, "a"); | |
| 436 | − | |
| 437 | − try { | |
| 438 | − const child = spawn(command[0]!, command.slice(1), { | |
| 439 | − detached: true, | |
| 440 | − stdio: ["ignore", logHandle.fd, logHandle.fd], | |
| 441 | − }); | |
| 442 | − child.unref(); | |
| 443 | − } finally { | |
| 444 | − await logHandle.close(); | |
| 445 | − } | |
| 446 | − }, | |
| 447 | − catch: toError, | |
| 448 | − }); | |
| 428 | + yield* fs.makeDirectory(dirname(DEFAULT_SERVER_LOG_FILE), { | |
| 429 | + recursive: true, | |
| 430 | + }).pipe(Effect.mapError(toError)); | |
| 431 | + const logHandle = yield* fs.open(DEFAULT_SERVER_LOG_FILE, { | |
| 432 | + flag: "a", | |
| 433 | + }).pipe(Effect.mapError(toError)); | |
| 434 | + | |
| 435 | + yield* Effect.try({ | |
| 436 | + try: () => { | |
| 437 | + const fd = Number(logHandle.fd); | |
| 438 | + const child = spawn(command[0]!, command.slice(1), { | |
| 439 | + detached: true, | |
| 440 | + stdio: ["ignore", fd, fd], | |
| 441 | + }); | |
| 442 | + child.unref(); | |
| 443 | + }, | |
| 444 | + catch: toError, | |
| 445 | + }); | |
| 446 | + }), | |
| 447 | + ); | |
| 449 | 448 | |
| 450 | 449 | type LocalServerPidRecord = { |
| 451 | 450 | pid?: number; |
| @@ -456,13 +455,21 @@ type LocalServerPidRecord = { · 4 unmodified lines | ||
| 456 | 455 | logFile?: string; |
| 457 | 456 | }; |
| 458 | 457 | |
| 459 | −const readPidRecord = (): Effect.Effect<LocalServerPidRecord | null, never, never> => | |
| 460 | − Effect.tryPromise({ | |
| 461 | − try: async () => { | |
| 462 | − const contents = await readFile(DEFAULT_SERVER_PID_FILE, "utf8"); | |
| 463 | − return JSON.parse(contents) as LocalServerPidRecord; | |
| 464 | − }, | |
| 465 | − catch: () => null, | |
| 458 | +const readPidRecord = (): Effect.Effect< | |
| 459 | + LocalServerPidRecord | null, | |
| 460 | + never, | |
| 461 | + FileSystem.FileSystem | |
| 462 | +> => | |
| 463 | + Effect.gen(function* () { | |
| 464 | + const fs = yield* FileSystem.FileSystem; | |
| 465 | + const contents = yield* fs.readFileString(DEFAULT_SERVER_PID_FILE, "utf8").pipe( | |
| 466 | + Effect.catchAll(() => Effect.succeed<string | null>(null)), | |
| 467 | + ); | |
| 468 | + if (contents === null) { | |
| 469 | + return null; | |
| 470 | + } | |
| 471 | + | |
| 472 | + return JSON.parse(contents) as LocalServerPidRecord; | |
| 466 | 473 | }).pipe(Effect.catchAll(() => Effect.succeed(null))); |
| 467 | 474 | |
| 468 | 475 | const isPidRunning = (pid: number): boolean => { |
| @@ -547,7 +554,9 @@ const renderDenoSandboxDetail = (denoVersion: string | null): string => · 78 unmodified lines | ||
| 547 | 554 | ? `deno ${denoVersion}` |
| 548 | 555 | : "deno not found (run `executor sandbox` to install)"; |
| 549 | 556 | |
| 550 | −const getServerStatus = (baseUrl: string): Effect.Effect<LocalServerStatus, Error, never> => | |
| 557 | +const getServerStatus = ( | |
| 558 | + baseUrl: string, | |
| 559 | +): Effect.Effect<LocalServerStatus, Error, FileSystem.FileSystem> => | |
| 551 | 560 | Effect.gen(function* () { |
| 552 | 561 | const pidRecord = yield* readPidRecord(); |
| 553 | 562 | const reachable = yield* isServerReachable(baseUrl); |
| @@ -647,20 +656,20 @@ const printText = (value: string) => · 93 unmodified lines | ||
| 647 | 656 | |
| 648 | 657 | const stopServer = (baseUrl: string) => |
| 649 | 658 | Effect.gen(function* () { |
| 659 | + const fs = yield* FileSystem.FileSystem; | |
| 660 | + const removePidFile = fs.remove(DEFAULT_SERVER_PID_FILE, { | |
| 661 | + force: true, | |
| 662 | + }).pipe(Effect.ignore); | |
| 650 | 663 | const pidRecord = yield* readPidRecord(); |
| 651 | 664 | const pid = typeof pidRecord?.pid === "number" ? pidRecord.pid : null; |
| 652 | 665 | |
| 653 | 666 | if (pid === null) { |
| 654 | − yield* Effect.tryPromise(() => rm(DEFAULT_SERVER_PID_FILE, { force: true })).pipe( | |
| 655 | − Effect.ignore, | |
| 656 | − ); | |
| 667 | + yield* removePidFile; | |
| 657 | 668 | return false; |
| 658 | 669 | } |
| 659 | 670 | |
| 660 | 671 | if (!isPidRunning(pid)) { |
| 661 | − yield* Effect.tryPromise(() => rm(DEFAULT_SERVER_PID_FILE, { force: true })).pipe( | |
| 662 | − Effect.ignore, | |
| 663 | − ); | |
| 672 | + yield* removePidFile; | |
| 664 | 673 | return false; |
| 665 | 674 | } |
| 666 | 675 | |
| @@ -671,7 +680,7 @@ const stopServer = (baseUrl: string) => · 4 unmodified lines | ||
| 671 | 680 | |
| 672 | 681 | yield* waitForReachability(baseUrl, false).pipe( |
| 673 | 682 | Effect.catchAll(() => |
| 674 | − Effect.tryPromise(() => rm(DEFAULT_SERVER_PID_FILE, { force: true })).pipe( | |
| 683 | + removePidFile.pipe( | |
| 675 | 684 | Effect.ignore, |
| 676 | 685 | Effect.zipRight(Effect.fail(executorAppEffectError("cli/main", `Timed out stopping local executor server pid ${pid}`))), |
| 677 | 686 | ), |
| @@ -1178,38 +1187,49 @@ const doctorCommand = Command.make( · 500 unmodified lines | ||
| 1178 | 1187 | ), |
| 1179 | 1188 | ).pipe(Command.withDescription("Check local executor install and daemon health")); |
| 1180 | 1189 | |
| 1181 | −const getDenoVersion = (): Effect.Effect<string | null, never, never> => | |
| 1182 | − Effect.tryPromise({ | |
| 1183 | − try: () => | |
| 1184 | − new Promise<string | null>((resolve) => { | |
| 1185 | − const denoExecutable = process.env.DENO_BIN?.trim() | |
| 1186 | − || (process.env.HOME && existsSync(`${process.env.HOME}/.deno/bin/deno`) | |
| 1187 | − ? `${process.env.HOME}/.deno/bin/deno` | |
| 1188 | − : "deno"); | |
| 1189 | − | |
| 1190 | − const child = spawn(denoExecutable, ["--version"], { | |
| 1191 | − stdio: ["ignore", "pipe", "ignore"], | |
| 1192 | − timeout: 5000, | |
| 1193 | − }); | |
| 1190 | +const getDenoVersion = (): Effect.Effect<string | null, never, FileSystem.FileSystem> => | |
| 1191 | + Effect.gen(function* () { | |
| 1192 | + const fs = yield* FileSystem.FileSystem; | |
| 1193 | + const configuredDenoExecutable = process.env.DENO_BIN?.trim(); | |
| 1194 | + const bundledDenoExecutable = process.env.HOME?.trim() | |
| 1195 | + ? `${process.env.HOME.trim()}/.deno/bin/deno` | |
| 1196 | + : null; | |
| 1197 | + const bundledDenoExists = bundledDenoExecutable === null | |
| 1198 | + ? false | |
| 1199 | + : yield* fs.exists(bundledDenoExecutable).pipe( | |
| 1200 | + Effect.catchAll(() => Effect.succeed(false)), | |
| 1201 | + ); | |
| 1202 | + const denoExecutable = configuredDenoExecutable | |
| 1203 | + || (bundledDenoExists ? bundledDenoExecutable : null) | |
| 1204 | + || "deno"; | |
| 1205 | + | |
| 1206 | + return yield* Effect.tryPromise({ | |
| 1207 | + try: () => | |
| 1208 | + new Promise<string | null>((resolveVersion) => { | |
| 1209 | + const child = spawn(denoExecutable, ["--version"], { | |
| 1210 | + stdio: ["ignore", "pipe", "ignore"], | |
| 1211 | + timeout: 5000, | |
| 1212 | + }); | |
| 1194 | 1213 | |
| 1195 | − let stdout = ""; | |
| 1196 | − child.stdout?.setEncoding("utf8"); | |
| 1197 | − child.stdout?.on("data", (chunk: string) => { | |
| 1198 | − stdout += chunk; | |
| 1199 | − }); | |
| 1214 | + let stdout = ""; | |
| 1215 | + child.stdout?.setEncoding("utf8"); | |
| 1216 | + child.stdout?.on("data", (chunk: string) => { | |
| 1217 | + stdout += chunk; | |
| 1218 | + }); | |
| 1200 | 1219 | |
| 1201 | − child.once("error", () => resolve(null)); | |
| 1202 | − child.once("close", (code) => { | |
| 1203 | − if (code !== 0) { | |
| 1204 | − resolve(null); | |
| 1205 | − return; | |
| 1206 | − } | |
| 1220 | + child.once("error", () => resolveVersion(null)); | |
| 1221 | + child.once("close", (code) => { | |
| 1222 | + if (code !== 0) { | |
| 1223 | + resolveVersion(null); | |
| 1224 | + return; | |
| 1225 | + } | |
| 1207 | 1226 | |
| 1208 | − const match = /deno\s+(\S+)/i.exec(stdout); | |
| 1209 | − resolve(match ? match[1] : null); | |
| 1210 | − }); | |
| 1211 | − }), | |
| 1212 | − catch: () => null, | |
| 1227 | + const match = /deno\s+(\S+)/i.exec(stdout); | |
| 1228 | + resolveVersion(match ? match[1] : null); | |
| 1229 | + }); | |
| 1230 | + }), | |
| 1231 | + catch: () => null, | |
| 1232 | + }).pipe(Effect.catchAll(() => Effect.succeed(null))); | |
| 1213 | 1233 | }).pipe(Effect.catchAll(() => Effect.succeed(null))); |
| 1214 | 1234 | |
| 1215 | 1235 | const sandboxCommand = Command.make( |
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import { spawn } from "node:child_process"; |
| 2 | −import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 3 | 2 | import { createServer } from "node:net"; |
| 4 | 3 | import { tmpdir } from "node:os"; |
| 5 | 4 | import { dirname, join } from "node:path"; |
| 6 | 5 | |
| 6 | +import { FileSystem } from "@effect/platform"; | |
| 7 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 7 | 8 | import * as Context from "effect/Context"; |
| 8 | 9 | import * as Effect from "effect/Effect"; |
| 9 | 10 | import * as Layer from "effect/Layer"; |
| @@ -11,6 +12,9 @@ import * as Layer from "effect/Layer"; · 1 unmodified line | ||
| 11 | 12 | import { buildDistributionPackage } from "./artifact"; |
| 12 | 13 | import { executorAppEffectError } from "../effect-errors"; |
| 13 | 14 | |
| 15 | +const toError = (cause: unknown): Error => | |
| 16 | + cause instanceof Error ? cause : new Error(String(cause)); | |
| 17 | + | |
| 14 | 18 | type CommandResult = { |
| 15 | 19 | readonly stdout: string; |
| 16 | 20 | readonly stderr: string; |
| @@ -173,16 +177,17 @@ const packPackage = (packageDir: string, outputDir: string) => · 156 unmodified lines | ||
| 173 | 177 | export const LocalDistributionHarnessLive = Layer.scoped( |
| 174 | 178 | DistributionHarness, |
| 175 | 179 | Effect.gen(function* () { |
| 180 | + const fs = yield* FileSystem.FileSystem; | |
| 176 | 181 | const tempRoot = yield* Effect.acquireRelease( |
| 177 | − Effect.tryPromise({ | |
| 178 | − try: () => mkdtemp(join(tmpdir(), "executor-distribution-")), | |
| 179 | − catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), | |
| 180 | − }), | |
| 181 | − (path) => Effect.tryPromise({ | |
| 182 | − try: () => rm(path, { recursive: true, force: true }), | |
| 183 | − catch: (cause) => | |
| 184 | − cause instanceof Error ? cause : new Error(String(cause ?? "temp dir cleanup failed")), | |
| 185 | − }).pipe(Effect.orDie), | |
| 182 | + fs.makeTempDirectory({ | |
| 183 | + directory: tmpdir(), | |
| 184 | + prefix: "executor-distribution-", | |
| 185 | + }).pipe(Effect.mapError(toError)), | |
| 186 | + (path) => | |
| 187 | + fs.remove(path, { recursive: true, force: true }).pipe( | |
| 188 | + Effect.mapError(toError), | |
| 189 | + Effect.orDie, | |
| 190 | + ), | |
| 186 | 191 | ); |
| 187 | 192 | |
| 188 | 193 | const packageDir = join(tempRoot, "package"); |
| @@ -193,14 +198,11 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 4 unmodified lines | ||
| 193 | 198 | const installedWorkspaceRoot = tempRoot; |
| 194 | 199 | const baseUrl = `http://127.0.0.1:${yield* allocatePort()}`; |
| 195 | 200 | |
| 196 | − yield* Effect.tryPromise({ | |
| 197 | − try: async () => { | |
| 198 | − await mkdir(prefixDir, { recursive: true }); | |
| 199 | − await mkdir(homeDir, { recursive: true }); | |
| 200 | − await mkdir(executorHome, { recursive: true }); | |
| 201 | − }, | |
| 202 | − catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), | |
| 203 | − }); | |
| 201 | + yield* Effect.all([ | |
| 202 | + fs.makeDirectory(prefixDir, { recursive: true }), | |
| 203 | + fs.makeDirectory(homeDir, { recursive: true }), | |
| 204 | + fs.makeDirectory(executorHome, { recursive: true }), | |
| 205 | + ]).pipe(Effect.mapError(toError)); | |
| 204 | 206 | |
| 205 | 207 | const artifact = yield* buildPackage(packageDir); |
| 206 | 208 | const tarballPath = yield* packPackage(packageDir, tempRoot); |
| @@ -263,16 +265,21 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 56 unmodified lines | ||
| 263 | 265 | }); |
| 264 | 266 | |
| 265 | 267 | const writeProjectConfig = (contents: string) => |
| 266 | − Effect.tryPromise({ | |
| 267 | − try: async () => { | |
| 268 | − for (const workspaceRoot of [stagedWorkspaceRoot, installedWorkspaceRoot]) { | |
| 268 | + Effect.forEach( | |
| 269 | + [stagedWorkspaceRoot, installedWorkspaceRoot], | |
| 270 | + (workspaceRoot) => | |
| 271 | + Effect.gen(function* () { | |
| 269 | 272 | const configDir = join(workspaceRoot, ".executor"); |
| 270 | − await mkdir(configDir, { recursive: true }); | |
| 271 | − await writeFile(join(configDir, "executor.jsonc"), contents, "utf8"); | |
| 272 | − } | |
| 273 | − }, | |
| 274 | − catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), | |
| 275 | − }); | |
| 273 | + yield* fs.makeDirectory(configDir, { recursive: true }).pipe( | |
| 274 | + Effect.mapError(toError), | |
| 275 | + ); | |
| 276 | + yield* fs.writeFileString( | |
| 277 | + join(configDir, "executor.jsonc"), | |
| 278 | + contents, | |
| 279 | + ).pipe(Effect.mapError(toError)); | |
| 280 | + }), | |
| 281 | + { discard: true }, | |
| 282 | + ); | |
| 276 | 283 | |
| 277 | 284 | return DistributionHarness.of({ |
| 278 | 285 | packageDir, |
| @@ -285,5 +292,5 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 6 unmodified lines | ||
| 285 | 292 | runInstalled, |
| 286 | 293 | fetchText, |
| 287 | 294 | }); |
| 288 | − }), | |
| 295 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 289 | 296 | ); |
| @@ -19,7 +19,7 @@ · 18 unmodified lines | ||
| 19 | 19 | }, |
| 20 | 20 | "apps/executor": { |
| 21 | 21 | "name": "executor", |
| 22 | − "version": "1.2.2", | |
| 22 | + "version": "1.2.3", | |
| 23 | 23 | "bin": { |
| 24 | 24 | "executor": "bin/executor", |
| 25 | 25 | }, |
| @@ -299,6 +299,7 @@ · 273 unmodified lines | ||
| 299 | 299 | "dependencies": { |
| 300 | 300 | "@effect/opentelemetry": "^0.61.0", |
| 301 | 301 | "@effect/platform": "catalog:", |
| 302 | + "@effect/platform-node": "catalog:", | |
| 302 | 303 | "@executor/control-plane": "workspace:*", |
| 303 | 304 | "@executor/executor-mcp": "workspace:*", |
| 304 | 305 | "@opentelemetry/api": "^1.9.0", |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { createServer } from "node:http"; |
| 2 | 2 | |
| 3 | −import { HttpApiBuilder, HttpServer } from "@effect/platform"; | |
| 4 | −import { NodeHttpServer } from "@effect/platform-node"; | |
| 3 | +import { FileSystem, HttpApiBuilder, HttpServer } from "@effect/platform"; | |
| 4 | +import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"; | |
| 5 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | 6 | import { |
| 7 | 7 | createControlPlaneApiLayer, |
| @@ -92,9 +92,15 @@ const closeScope = (scope: Scope.CloseableScope) => · 84 unmodified lines | ||
| 92 | 92 | Scope.close(scope, Exit.void).pipe(Effect.orDie); |
| 93 | 93 | |
| 94 | 94 | const startControlPlaneServer = async (): Promise<ApiServer> => { |
| 95 | + const workspaceRoot = await Effect.runPromise( | |
| 96 | + FileSystem.FileSystem.pipe( | |
| 97 | + Effect.flatMap((fs) => fs.makeTempDirectory({ prefix: "executor-react-test-" })), | |
| 98 | + Effect.provide(NodeFileSystem.layer), | |
| 99 | + ), | |
| 100 | + ); | |
| 95 | 101 | const runtime = await Effect.runPromise( |
| 96 | 102 | createControlPlaneRuntime({ |
| 97 | − workspaceRoot: mkdtempSync(join(tmpdir(), "executor-react-test-")), | |
| 103 | + workspaceRoot, | |
| 98 | 104 | }), |
| 99 | 105 | ); |
| 100 | 106 | const scope = await Effect.runPromise(Scope.make()); |
| @@ -747,6 +753,3 @@ describe("executor-react source hooks", () => { · 646 unmodified lines | ||
| 747 | 753 | 60_000, |
| 748 | 754 | ); |
| 749 | 755 | }); |
| 750 | −import { mkdtempSync } from "node:fs"; | |
| 751 | −import { tmpdir } from "node:os"; | |
| 752 | −import { join } from "node:path"; | |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { spawnSync } from "node:child_process"; |
| 2 | −import { existsSync } from "node:fs"; | |
| 3 | 2 | import { fileURLToPath } from "node:url"; |
| 4 | 3 | |
| 5 | 4 | import type { |
| @@ -84,7 +83,11 @@ const defaultDenoExecutable = (): string => { · 78 unmodified lines | ||
| 84 | 83 | const home = process.env.HOME?.trim(); |
| 85 | 84 | if (home) { |
| 86 | 85 | const installedPath = `${home}/.deno/bin/deno`; |
| 87 | − if (existsSync(installedPath)) { | |
| 86 | + const installedResult = spawnSync(installedPath, ["--version"], { | |
| 87 | + stdio: "ignore", | |
| 88 | + timeout: 5000, | |
| 89 | + }); | |
| 90 | + if (installedResult.error === undefined && installedResult.status === 0) { | |
| 88 | 91 | return installedPath; |
| 89 | 92 | } |
| 90 | 93 | } |
| @@ -1,6 +1,7 @@ | ||
| 1 | −import { mkdtempSync, readFileSync } from "node:fs"; | |
| 2 | 1 | import { tmpdir } from "node:os"; |
| 3 | 2 | import { join } from "node:path"; |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 4 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 4 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | |
| @@ -325,9 +326,34 @@ const makeSource = (input: { · 318 unmodified lines | ||
| 325 | 326 | updatedAt: 0, |
| 326 | 327 | }); |
| 327 | 328 | |
| 329 | +const makeTempDirectory = (prefix: string) => | |
| 330 | + FileSystem.FileSystem.pipe( | |
| 331 | + Effect.flatMap((fs) => | |
| 332 | + fs.makeTempDirectory({ | |
| 333 | + directory: tmpdir(), | |
| 334 | + prefix, | |
| 335 | + }) | |
| 336 | + ), | |
| 337 | + Effect.provide(NodeFileSystem.layer), | |
| 338 | + ); | |
| 339 | + | |
| 340 | +const readTextFile = (path: string) => | |
| 341 | + FileSystem.FileSystem.pipe( | |
| 342 | + Effect.flatMap((fs) => fs.readFileString(path, "utf8")), | |
| 343 | + Effect.provide(NodeFileSystem.layer), | |
| 344 | + ); | |
| 345 | + | |
| 346 | +const fileExists = (path: string) => | |
| 347 | + FileSystem.FileSystem.pipe( | |
| 348 | + Effect.flatMap((fs) => fs.exists(path)), | |
| 349 | + Effect.provide(NodeFileSystem.layer), | |
| 350 | + ); | |
| 351 | + | |
| 328 | 352 | describe("source-type-declarations", () => { |
| 329 | 353 | it("writes per-source and aggregate declaration files", async () => { |
| 330 | − const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-types-")); | |
| 354 | + const workspaceRoot = await Effect.runPromise( | |
| 355 | + makeTempDirectory("executor-types-"), | |
| 356 | + ); | |
| 331 | 357 | const context = { |
| 332 | 358 | cwd: workspaceRoot, |
| 333 | 359 | workspaceRoot, |
| @@ -355,13 +381,13 @@ describe("source-type-declarations", () => { · 21 unmodified lines | ||
| 355 | 381 | ], |
| 356 | 382 | })); |
| 357 | 383 | |
| 358 | − const sourceDeclaration = readFileSync( | |
| 359 | − join(workspaceRoot, ".executor", "types", "sources", "src_linear.d.ts"), | |
| 360 | − "utf8", | |
| 384 | + const sourceDeclaration = await Effect.runPromise( | |
| 385 | + readTextFile( | |
| 386 | + join(workspaceRoot, ".executor", "types", "sources", "src_linear.d.ts"), | |
| 387 | + ), | |
| 361 | 388 | ); |
| 362 | − const aggregateDeclaration = readFileSync( | |
| 363 | − join(workspaceRoot, ".executor", "types", "index.d.ts"), | |
| 364 | − "utf8", | |
| 389 | + const aggregateDeclaration = await Effect.runPromise( | |
| 390 | + readTextFile(join(workspaceRoot, ".executor", "types", "index.d.ts")), | |
| 365 | 391 | ); |
| 366 | 392 | |
| 367 | 393 | expect(sourceDeclaration).toContain("export interface SourceTools_src_linear"); |
| @@ -381,7 +407,9 @@ describe("source-type-declarations", () => { · 13 unmodified lines | ||
| 381 | 407 | }); |
| 382 | 408 | |
| 383 | 409 | it("removes stale source declarations when the source disappears", async () => { |
| 384 | − const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-types-stale-")); | |
| 410 | + const workspaceRoot = await Effect.runPromise( | |
| 411 | + makeTempDirectory("executor-types-stale-"), | |
| 412 | + ); | |
| 385 | 413 | const context = { |
| 386 | 414 | cwd: workspaceRoot, |
| 387 | 415 | workspaceRoot, |
| @@ -404,22 +432,24 @@ describe("source-type-declarations", () => { · 16 unmodified lines | ||
| 404 | 432 | entries: [], |
| 405 | 433 | })); |
| 406 | 434 | |
| 407 | − const aggregateDeclaration = readFileSync( | |
| 408 | − join(workspaceRoot, ".executor", "types", "index.d.ts"), | |
| 409 | − "utf8", | |
| 435 | + const aggregateDeclaration = await Effect.runPromise( | |
| 436 | + readTextFile(join(workspaceRoot, ".executor", "types", "index.d.ts")), | |
| 410 | 437 | ); |
| 411 | 438 | |
| 412 | 439 | expect(aggregateDeclaration).toContain("export type ExecutorSourceTools = {};"); |
| 413 | − expect(() => | |
| 414 | − readFileSync( | |
| 415 | − join(workspaceRoot, ".executor", "types", "sources", "src_linear.d.ts"), | |
| 416 | − "utf8", | |
| 417 | − ) | |
| 418 | − ).toThrow(); | |
| 440 | + expect( | |
| 441 | + await Effect.runPromise( | |
| 442 | + fileExists( | |
| 443 | + join(workspaceRoot, ".executor", "types", "sources", "src_linear.d.ts"), | |
| 444 | + ), | |
| 445 | + ), | |
| 446 | + ).toBe(false); | |
| 419 | 447 | }); |
| 420 | 448 | |
| 421 | 449 | it("falls back to unknown for unsupported declaration-only shape nodes", async () => { |
| 422 | − const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-types-unsupported-")); | |
| 450 | + const workspaceRoot = await Effect.runPromise( | |
| 451 | + makeTempDirectory("executor-types-unsupported-"), | |
| 452 | + ); | |
| 423 | 453 | const context = { |
| 424 | 454 | cwd: workspaceRoot, |
| 425 | 455 | workspaceRoot, |
| @@ -525,9 +555,10 @@ describe("source-type-declarations", () => { · 99 unmodified lines | ||
| 525 | 555 | entries: [{ source: makeSource({ id: "src_linear" }), snapshot }], |
| 526 | 556 | })); |
| 527 | 557 | |
| 528 | − const sourceDeclaration = readFileSync( | |
| 529 | − join(workspaceRoot, ".executor", "types", "sources", "src_linear.d.ts"), | |
| 530 | − "utf8", | |
| 558 | + const sourceDeclaration = await Effect.runPromise( | |
| 559 | + readTextFile( | |
| 560 | + join(workspaceRoot, ".executor", "types", "sources", "src_linear.d.ts"), | |
| 561 | + ), | |
| 531 | 562 | ); |
| 532 | 563 | |
| 533 | 564 | expect(sourceDeclaration).toContain("unsupportedNot?: unknown;"); |
| @@ -535,7 +566,9 @@ describe("source-type-declarations", () => { · 1 unmodified line | ||
| 535 | 566 | }); |
| 536 | 567 | |
| 537 | 568 | it("renders object unions as discriminated unions with shared fields", async () => { |
| 538 | − const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-types-union-")); | |
| 569 | + const workspaceRoot = await Effect.runPromise( | |
| 570 | + makeTempDirectory("executor-types-union-"), | |
| 571 | + ); | |
| 539 | 572 | const context = { |
| 540 | 573 | cwd: workspaceRoot, |
| 541 | 574 | workspaceRoot, |
| @@ -692,9 +725,10 @@ describe("source-type-declarations", () => { · 150 unmodified lines | ||
| 692 | 725 | entries: [{ source: makeSource({ id: "src_linear" }), snapshot }], |
| 693 | 726 | })); |
| 694 | 727 | |
| 695 | − const sourceDeclaration = readFileSync( | |
| 696 | − join(workspaceRoot, ".executor", "types", "sources", "src_linear.d.ts"), | |
| 697 | − "utf8", | |
| 728 | + const sourceDeclaration = await Effect.runPromise( | |
| 729 | + readTextFile( | |
| 730 | + join(workspaceRoot, ".executor", "types", "sources", "src_linear.d.ts"), | |
| 731 | + ), | |
| 698 | 732 | ); |
| 699 | 733 | |
| 700 | 734 | expect(sourceDeclaration).toContain("teams: (args?: LinearTeamsCall) => Promise<{"); |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { createServer } from "node:http"; |
| 2 | −import { readFileSync } from "node:fs"; | |
| 3 | 2 | import { join } from "node:path"; |
| 4 | 3 | |
| 5 | 4 | import { FileSystem } from "@effect/platform"; |
| @@ -414,7 +413,7 @@ describe("control-plane-runtime", () => { · 408 unmodified lines | ||
| 414 | 413 | ); |
| 415 | 414 | |
| 416 | 415 | const configPath = join(workspaceRoot, ".executor", "executor.jsonc"); |
| 417 | − const createdConfig = JSON.parse(readFileSync(configPath, "utf8")) as { | |
| 416 | + const createdConfig = JSON.parse(yield* fs.readFileString(configPath, "utf8")) as { | |
| 418 | 417 | sources?: Record<string, { kind: string; connection: { endpoint: string } }>; |
| 419 | 418 | }; |
| 420 | 419 | expect(createdConfig.sources?.github?.kind).toBe("openapi"); |
| @@ -432,7 +431,7 @@ describe("control-plane-runtime", () => { · 11 unmodified lines | ||
| 432 | 431 | ); |
| 433 | 432 | expect(removed.removed).toBe(true); |
| 434 | 433 | |
| 435 | − const removedConfig = JSON.parse(readFileSync(configPath, "utf8")) as { | |
| 434 | + const removedConfig = JSON.parse(yield* fs.readFileString(configPath, "utf8")) as { | |
| 436 | 435 | sources?: Record<string, unknown>; |
| 437 | 436 | }; |
| 438 | 437 | expect(removedConfig.sources?.github).toBeUndefined(); |
| @@ -1,6 +1,7 @@ | ||
| 1 | −import { mkdtempSync } from "node:fs"; | |
| 2 | 1 | import { tmpdir } from "node:os"; |
| 3 | 2 | import { join } from "node:path"; |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 4 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 4 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| @@ -40,8 +41,12 @@ const makeExecutionResolver = () => { · 33 unmodified lines | ||
| 40 | 41 | }; |
| 41 | 42 | |
| 42 | 43 | const makeRuntime = Effect.acquireRelease( |
| 43 | − Effect.sync(() => { | |
| 44 | − const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-execution-http-")); | |
| 44 | + Effect.gen(function* () { | |
| 45 | + const fs = yield* FileSystem.FileSystem; | |
| 46 | + const workspaceRoot = yield* fs.makeTempDirectory({ | |
| 47 | + directory: tmpdir(), | |
| 48 | + prefix: "executor-execution-http-", | |
| 49 | + }); | |
| 45 | 50 | |
| 46 | 51 | return { |
| 47 | 52 | workspaceRoot, |
| @@ -49,6 +54,7 @@ const makeRuntime = Effect.acquireRelease( · 1 unmodified line | ||
| 49 | 54 | homeStateDirectory: join(workspaceRoot, ".executor-home-state"), |
| 50 | 55 | }; |
| 51 | 56 | }).pipe( |
| 57 | + Effect.provide(NodeFileSystem.layer), | |
| 52 | 58 | Effect.flatMap(({ workspaceRoot, homeConfigPath, homeStateDirectory }) => |
| 53 | 59 | createControlPlaneRuntime({ |
| 54 | 60 | localDataDir: ":memory:", |
| @@ -5,9 +5,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 4 unmodified lines | ||
| 5 | 5 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 6 | 6 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 7 | 7 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 8 | −import { mkdtempSync } from "node:fs"; | |
| 9 | 8 | import { tmpdir } from "node:os"; |
| 10 | 9 | import { join } from "node:path"; |
| 10 | +import { FileSystem } from "@effect/platform"; | |
| 11 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 11 | 12 | import { describe, expect, it } from "@effect/vitest"; |
| 12 | 13 | import { assertTrue } from "@effect/vitest/utils"; |
| 13 | 14 | import * as Effect from "effect/Effect"; |
| @@ -283,7 +284,11 @@ describe("execution-mcp-resume", () => { · 269 unmodified lines | ||
| 283 | 284 | it.scoped("keeps live form elicitation resumable without replaying the run", () => |
| 284 | 285 | Effect.gen(function* () { |
| 285 | 286 | const mcpServer = yield* makeFormElicitationServer; |
| 286 | − const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-execution-mcp-resume-")); | |
| 287 | + const fs = yield* FileSystem.FileSystem; | |
| 288 | + const workspaceRoot = yield* fs.makeTempDirectory({ | |
| 289 | + directory: tmpdir(), | |
| 290 | + prefix: "executor-execution-mcp-resume-", | |
| 291 | + }); | |
| 287 | 292 | const runtime = yield* Effect.acquireRelease( |
| 288 | 293 | createControlPlaneRuntime({ |
| 289 | 294 | localDataDir: ":memory:", |
| @@ -357,7 +362,7 @@ describe("execution-mcp-resume", () => { · 67 unmodified lines | ||
| 357 | 362 | expect(resumed.pendingInteraction).toBeNull(); |
| 358 | 363 | expect(resumed.execution.resultJson).toContain("approved:from-control-plane"); |
| 359 | 364 | |
| 360 | − }), | |
| 365 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 361 | 366 | 60_000, |
| 362 | 367 | ); |
| 363 | 368 | }); |
| @@ -1,6 +1,7 @@ | ||
| 1 | −import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; | |
| 2 | 1 | import { tmpdir } from "node:os"; |
| 3 | 2 | import { join } from "node:path"; |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 4 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 4 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | |
| @@ -12,26 +13,33 @@ import { withControlPlaneClient } from "./test-http-client"; · 5 unmodified lines | ||
| 12 | 13 | const writeProjectConfig = ( |
| 13 | 14 | workspaceRoot: string, |
| 14 | 15 | config: Record<string, unknown>, |
| 15 | −) => { | |
| 16 | +) => | |
| 17 | + Effect.gen(function* () { | |
| 18 | + const fs = yield* FileSystem.FileSystem; | |
| 16 | 19 | const configDirectory = join(workspaceRoot, ".executor"); |
| 17 | − mkdirSync(configDirectory, { recursive: true }); | |
| 18 | − writeFileSync( | |
| 19 | − join(configDirectory, "executor.jsonc"), | |
| 20 | − `${JSON.stringify(config, null, 2)}\n`, | |
| 21 | − "utf8", | |
| 22 | − ); | |
| 23 | −}; | |
| 20 | + yield* fs.makeDirectory(configDirectory, { recursive: true }); | |
| 21 | + yield* fs.writeFileString( | |
| 22 | + join(configDirectory, "executor.jsonc"), | |
| 23 | + `${JSON.stringify(config, null, 2)}\n`, | |
| 24 | + ); | |
| 25 | + }); | |
| 24 | 26 | |
| 25 | 27 | const makeRuntime = (config: Record<string, unknown>) => { |
| 26 | − const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-runtime-config-")); | |
| 27 | − writeProjectConfig(workspaceRoot, config); | |
| 28 | − | |
| 29 | 28 | return Effect.acquireRelease( |
| 30 | − createControlPlaneRuntime({ | |
| 31 | − workspaceRoot, | |
| 32 | − homeConfigPath: join(workspaceRoot, ".executor-home.jsonc"), | |
| 33 | − homeStateDirectory: join(workspaceRoot, ".executor-home-state"), | |
| 34 | − }), | |
| 29 | + Effect.gen(function* () { | |
| 30 | + const fs = yield* FileSystem.FileSystem; | |
| 31 | + const workspaceRoot = yield* fs.makeTempDirectory({ | |
| 32 | + directory: tmpdir(), | |
| 33 | + prefix: "executor-runtime-config-", | |
| 34 | + }); | |
| 35 | + yield* writeProjectConfig(workspaceRoot, config); | |
| 36 | + | |
| 37 | + return yield* createControlPlaneRuntime({ | |
| 38 | + workspaceRoot, | |
| 39 | + homeConfigPath: join(workspaceRoot, ".executor-home.jsonc"), | |
| 40 | + homeStateDirectory: join(workspaceRoot, ".executor-home-state"), | |
| 41 | + }); | |
| 42 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 35 | 43 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| 36 | 44 | ); |
| 37 | 45 | }; |
| @@ -1,9 +1,11 @@ | ||
| 1 | +import { FileSystem } from "@effect/platform"; | |
| 1 | 2 | import * as Effect from "effect/Effect"; |
| 2 | 3 | import * as Exit from "effect/Exit"; |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| 4 | 5 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 5 | 6 | import * as Scope from "effect/Scope"; |
| 6 | 7 | import { NodeFileSystem } from "@effect/platform-node"; |
| 8 | +import { clearAllMcpConnectionPools } from "@executor/source-mcp"; | |
| 7 | 9 | |
| 8 | 10 | import { type ControlPlaneApiRuntimeContext } from "#api"; |
| 9 | 11 | import type { LocalInstallation } from "#schema"; |
| @@ -217,6 +219,7 @@ export const createControlPlaneRuntime = ( · 207 unmodified lines | ||
| 217 | 219 | |
| 218 | 220 | const installationStore = LocalInstallationStore; |
| 219 | 221 | const workspaceConfigStore = LocalWorkspaceConfigStore; |
| 222 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 220 | 223 | |
| 221 | 224 | const localInstallation = yield* installationStore.getOrProvision({ |
| 222 | 225 | context: localWorkspaceContext, |
| @@ -227,7 +230,10 @@ export const createControlPlaneRuntime = ( · 4 unmodified lines | ||
| 227 | 230 | ), |
| 228 | 231 | ); |
| 229 | 232 | |
| 230 | − const persistence = createLocalControlPlanePersistence(localWorkspaceContext); | |
| 233 | + const persistence = createLocalControlPlanePersistence( | |
| 234 | + localWorkspaceContext, | |
| 235 | + fileSystem, | |
| 236 | + ); | |
| 231 | 237 | const rows = persistence.rows; |
| 232 | 238 | |
| 233 | 239 | const loadedLocalConfig = yield* workspaceConfigStore.load( |
| @@ -276,6 +282,9 @@ export const createControlPlaneRuntime = ( · 42 unmodified lines | ||
| 276 | 282 | localInstallation, |
| 277 | 283 | managedRuntime, |
| 278 | 284 | runtimeLayer: concreteRuntimeLayer as RuntimeControlPlaneLayer, |
| 279 | − close: () => managedRuntime.dispose().catch(() => {}), | |
| 285 | + close: async () => { | |
| 286 | + await clearAllMcpConnectionPools().catch(() => undefined); | |
| 287 | + await managedRuntime.dispose().catch(() => undefined); | |
| 288 | + }, | |
| 280 | 289 | }; |
| 281 | 290 | }).pipe(Effect.provide(NodeFileSystem.layer)); |
| @@ -1,7 +1,6 @@ | ||
| 1 | −import { mkdtempSync } from "node:fs"; | |
| 2 | −import { mkdir, writeFile } from "node:fs/promises"; | |
| 3 | 1 | import { tmpdir } from "node:os"; |
| 4 | 2 | import { join } from "node:path"; |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 5 | 4 | import { NodeFileSystem } from "@effect/platform-node"; |
| 6 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | 6 | import * as Effect from "effect/Effect"; |
| @@ -15,18 +14,25 @@ import { · 7 unmodified lines | ||
| 15 | 14 | } from "./config"; |
| 16 | 15 | |
| 17 | 16 | const makeWorkspaceRoot = () => |
| 18 | − mkdtempSync(join(tmpdir(), "executor-local-config-")); | |
| 17 | + FileSystem.FileSystem.pipe( | |
| 18 | + Effect.flatMap((fs) => | |
| 19 | + fs.makeTempDirectory({ | |
| 20 | + directory: tmpdir(), | |
| 21 | + prefix: "executor-local-config-", | |
| 22 | + }) | |
| 23 | + ), | |
| 24 | + ); | |
| 19 | 25 | |
| 20 | 26 | describe("local-config", () => { |
| 21 | 27 | it.effect("parses jsonc project config with comments and trailing commas", () => |
| 22 | 28 | Effect.gen(function* () { |
| 23 | − const workspaceRoot = makeWorkspaceRoot(); | |
| 29 | + const fs = yield* FileSystem.FileSystem; | |
| 30 | + const workspaceRoot = yield* makeWorkspaceRoot(); | |
| 24 | 31 | const configDirectory = join(workspaceRoot, ".executor"); |
| 25 | − yield* Effect.promise(() => mkdir(configDirectory, { recursive: true })); | |
| 26 | − yield* Effect.promise(() => | |
| 27 | − writeFile( | |
| 28 | − join(configDirectory, "executor.jsonc"), | |
| 29 | − `{ | |
| 32 | + yield* fs.makeDirectory(configDirectory, { recursive: true }); | |
| 33 | + yield* fs.writeFileString( | |
| 34 | + join(configDirectory, "executor.jsonc"), | |
| 35 | + `{ | |
| 30 | 36 | "runtime": "ses", |
| 31 | 37 | // local workspace config |
| 32 | 38 | "sources": { |
| @@ -42,8 +48,6 @@ describe("local-config", () => { · 9 unmodified lines | ||
| 42 | 48 | }, |
| 43 | 49 | } |
| 44 | 50 | `, |
| 45 | − "utf8", | |
| 46 | − ), | |
| 47 | 51 | ); |
| 48 | 52 | |
| 49 | 53 | const context = yield* resolveLocalWorkspaceContext({ workspaceRoot }); |
| @@ -60,13 +64,13 @@ describe("local-config", () => { · 10 unmodified lines | ||
| 60 | 64 | |
| 61 | 65 | it.effect("reports jsonc syntax errors with line and column details", () => |
| 62 | 66 | Effect.gen(function* () { |
| 63 | − const workspaceRoot = makeWorkspaceRoot(); | |
| 67 | + const fs = yield* FileSystem.FileSystem; | |
| 68 | + const workspaceRoot = yield* makeWorkspaceRoot(); | |
| 64 | 69 | const configDirectory = join(workspaceRoot, ".executor"); |
| 65 | − yield* Effect.promise(() => mkdir(configDirectory, { recursive: true })); | |
| 66 | − yield* Effect.promise(() => | |
| 67 | − writeFile( | |
| 68 | − join(configDirectory, "executor.jsonc"), | |
| 69 | − `{ | |
| 70 | + yield* fs.makeDirectory(configDirectory, { recursive: true }); | |
| 71 | + yield* fs.writeFileString( | |
| 72 | + join(configDirectory, "executor.jsonc"), | |
| 73 | + `{ | |
| 70 | 74 | "sources": { |
| 71 | 75 | "github": { |
| 72 | 76 | "kind": "openapi" |
| @@ -77,8 +81,6 @@ describe("local-config", () => { · 4 unmodified lines | ||
| 77 | 81 | } |
| 78 | 82 | } |
| 79 | 83 | `, |
| 80 | − "utf8", | |
| 81 | − ), | |
| 82 | 84 | ); |
| 83 | 85 | |
| 84 | 86 | const context = yield* resolveLocalWorkspaceContext({ workspaceRoot }); |
| @@ -1,6 +1,7 @@ | ||
| 1 | −import { existsSync, mkdtempSync, statSync } from "node:fs"; | |
| 2 | 1 | import { tmpdir } from "node:os"; |
| 3 | 2 | import { join } from "node:path"; |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 4 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 4 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | |
| @@ -11,26 +12,36 @@ import { · 4 unmodified lines | ||
| 11 | 12 | writeLocalControlPlaneState, |
| 12 | 13 | } from "./control-plane-store"; |
| 13 | 14 | |
| 14 | −const makeContext = (): ResolvedLocalWorkspaceContext => { | |
| 15 | − const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-control-plane-store-")); | |
| 15 | +const makeContext = (): Effect.Effect< | |
| 16 | + ResolvedLocalWorkspaceContext, | |
| 17 | + never, | |
| 18 | + FileSystem.FileSystem | |
| 19 | +> => | |
| 20 | + Effect.gen(function* () { | |
| 21 | + const fs = yield* FileSystem.FileSystem; | |
| 22 | + const workspaceRoot = yield* fs.makeTempDirectory({ | |
| 23 | + directory: tmpdir(), | |
| 24 | + prefix: "executor-control-plane-store-", | |
| 25 | + }).pipe(Effect.orDie); | |
| 16 | 26 | |
| 17 | − return { | |
| 18 | − cwd: workspaceRoot, | |
| 19 | − workspaceRoot, | |
| 20 | − workspaceName: "executor-control-plane-store", | |
| 21 | − configDirectory: join(workspaceRoot, ".executor"), | |
| 22 | − projectConfigPath: join(workspaceRoot, ".executor", "executor.jsonc"), | |
| 23 | − homeConfigPath: join(workspaceRoot, ".executor-home.jsonc"), | |
| 24 | − homeStateDirectory: join(workspaceRoot, ".executor-home-state"), | |
| 25 | − artifactsDirectory: join(workspaceRoot, ".executor", "artifacts"), | |
| 26 | − stateDirectory: join(workspaceRoot, ".executor", "state"), | |
| 27 | − }; | |
| 28 | −}; | |
| 27 | + return { | |
| 28 | + cwd: workspaceRoot, | |
| 29 | + workspaceRoot, | |
| 30 | + workspaceName: "executor-control-plane-store", | |
| 31 | + configDirectory: join(workspaceRoot, ".executor"), | |
| 32 | + projectConfigPath: join(workspaceRoot, ".executor", "executor.jsonc"), | |
| 33 | + homeConfigPath: join(workspaceRoot, ".executor-home.jsonc"), | |
| 34 | + homeStateDirectory: join(workspaceRoot, ".executor-home-state"), | |
| 35 | + artifactsDirectory: join(workspaceRoot, ".executor", "artifacts"), | |
| 36 | + stateDirectory: join(workspaceRoot, ".executor", "state"), | |
| 37 | + }; | |
| 38 | + }); | |
| 29 | 39 | |
| 30 | 40 | describe("local-control-plane-store", () => { |
| 31 | 41 | it.effect("stores secret-bearing control-plane state outside the workspace", () => |
| 32 | 42 | Effect.gen(function* () { |
| 33 | − const context = makeContext(); | |
| 43 | + const fs = yield* FileSystem.FileSystem; | |
| 44 | + const context = yield* makeContext(); | |
| 34 | 45 | const expectedPath = localControlPlaneStatePath(context); |
| 35 | 46 | const workspacePath = join(context.stateDirectory, "control-plane-state.json"); |
| 36 | 47 | |
| @@ -52,16 +63,16 @@ describe("local-control-plane-store", () => { · 15 unmodified lines | ||
| 52 | 63 | }); |
| 53 | 64 | |
| 54 | 65 | expect(expectedPath.startsWith(context.homeStateDirectory)).toBe(true); |
| 55 | − expect(existsSync(expectedPath)).toBe(true); | |
| 56 | − expect(existsSync(workspacePath)).toBe(false); | |
| 66 | + expect(yield* fs.exists(expectedPath)).toBe(true); | |
| 67 | + expect(yield* fs.exists(workspacePath)).toBe(false); | |
| 57 | 68 | |
| 58 | 69 | const loaded = yield* loadLocalControlPlaneState(context); |
| 59 | 70 | expect(loaded.version).toBe(1); |
| 60 | 71 | expect(loaded.secretMaterials).toEqual([]); |
| 61 | 72 | |
| 62 | 73 | if (process.platform !== "win32") { |
| 63 | − expect(statSync(expectedPath).mode & 0o777).toBe(0o600); | |
| 74 | + expect((yield* fs.stat(expectedPath)).mode & 0o777).toBe(0o600); | |
| 64 | 75 | } |
| 65 | − }), | |
| 76 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 66 | 77 | ); |
| 67 | 78 | }); |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | −import { existsSync } from "node:fs"; | |
| 3 | −import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; | |
| 4 | 2 | import { dirname, join } from "node:path"; |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 4 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | 7 | AuthArtifactSchema, |
| @@ -60,7 +60,7 @@ export type LocalControlPlanePersistence = { · 52 unmodified lines | ||
| 60 | 60 | close: () => Promise<void>; |
| 61 | 61 | }; |
| 62 | 62 | |
| 63 | −const decodeLocalControlPlaneState = Schema.decodeUnknownSync( | |
| 63 | +const decodeLocalControlPlaneState = Schema.decodeUnknown( | |
| 64 | 64 | LocalControlPlaneStateSchema, |
| 65 | 65 | ); |
| 66 | 66 | |
| @@ -118,54 +118,74 @@ const localControlPlaneStatePath = ( · 51 unmodified lines | ||
| 118 | 118 | LOCAL_CONTROL_PLANE_STATE_BASENAME, |
| 119 | 119 | ); |
| 120 | 120 | |
| 121 | −const readStateFromDisk = async ( | |
| 121 | +const bindFileSystem = <A, E>( | |
| 122 | + fileSystem: FileSystem.FileSystem, | |
| 123 | + effect: Effect.Effect<A, E, FileSystem.FileSystem>, | |
| 124 | +): Effect.Effect<A, E, never> => | |
| 125 | + effect.pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); | |
| 126 | + | |
| 127 | +const bindNodeFileSystem = <A, E>( | |
| 128 | + effect: Effect.Effect<A, E, FileSystem.FileSystem>, | |
| 129 | +): Effect.Effect<A, E, never> => | |
| 130 | + effect.pipe(Effect.provide(NodeFileSystem.layer)); | |
| 131 | + | |
| 132 | +const readStateFromDisk = ( | |
| 122 | 133 | context: ResolvedLocalWorkspaceContext, |
| 123 | −): Promise<LocalControlPlaneState> => { | |
| 124 | − const path = localControlPlaneStatePath(context); | |
| 125 | − if (!existsSync(path)) { | |
| 126 | − return defaultLocalControlPlaneState(); | |
| 127 | − } | |
| 134 | +): Effect.Effect<LocalControlPlaneState, LocalFileSystemError, FileSystem.FileSystem> => | |
| 135 | + Effect.gen(function* () { | |
| 136 | + const fs = yield* FileSystem.FileSystem; | |
| 137 | + const path = localControlPlaneStatePath(context); | |
| 138 | + const exists = yield* fs.exists(path).pipe( | |
| 139 | + Effect.mapError(mapFileSystemError(path, "check control plane state path")), | |
| 140 | + ); | |
| 141 | + if (!exists) { | |
| 142 | + return defaultLocalControlPlaneState(); | |
| 143 | + } | |
| 128 | 144 | |
| 129 | − const content = await readFile(path, "utf8"); | |
| 130 | − return decodeLocalControlPlaneState(JSON.parse(content) as unknown); | |
| 131 | −}; | |
| 145 | + const content = yield* fs.readFileString(path, "utf8").pipe( | |
| 146 | + Effect.mapError(mapFileSystemError(path, "read control plane state")), | |
| 147 | + ); | |
| 148 | + const parsed = yield* Effect.try({ | |
| 149 | + try: () => JSON.parse(content) as unknown, | |
| 150 | + catch: mapFileSystemError(path, "parse control plane state"), | |
| 151 | + }); | |
| 152 | + return yield* decodeLocalControlPlaneState(parsed).pipe( | |
| 153 | + Effect.mapError(mapFileSystemError(path, "decode control plane state")), | |
| 154 | + ); | |
| 155 | + }); | |
| 132 | 156 | |
| 133 | −const writeStateToDisk = async ( | |
| 157 | +const writeStateToDisk = ( | |
| 134 | 158 | context: ResolvedLocalWorkspaceContext, |
| 135 | 159 | state: LocalControlPlaneState, |
| 136 | −): Promise<void> => { | |
| 137 | − const path = localControlPlaneStatePath(context); | |
| 138 | − await mkdir(dirname(path), { recursive: true }); | |
| 139 | − const tempPath = `${path}.${randomUUID()}.tmp`; | |
| 140 | − await writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`, { | |
| 141 | − encoding: "utf8", | |
| 142 | − mode: 0o600, | |
| 160 | +): Effect.Effect<void, LocalFileSystemError, FileSystem.FileSystem> => | |
| 161 | + Effect.gen(function* () { | |
| 162 | + const fs = yield* FileSystem.FileSystem; | |
| 163 | + const path = localControlPlaneStatePath(context); | |
| 164 | + const tempPath = `${path}.${randomUUID()}.tmp`; | |
| 165 | + | |
| 166 | + yield* fs.makeDirectory(dirname(path), { recursive: true }).pipe( | |
| 167 | + Effect.mapError(mapFileSystemError(dirname(path), "create control plane state directory")), | |
| 168 | + ); | |
| 169 | + yield* fs.writeFileString(tempPath, `${JSON.stringify(state, null, 2)}\n`, { | |
| 170 | + mode: 0o600, | |
| 171 | + }).pipe( | |
| 172 | + Effect.mapError(mapFileSystemError(tempPath, "write control plane state")), | |
| 173 | + ); | |
| 174 | + yield* fs.rename(tempPath, path).pipe( | |
| 175 | + Effect.mapError(mapFileSystemError(path, "replace control plane state")), | |
| 176 | + ); | |
| 143 | 177 | }); |
| 144 | − await rename(tempPath, path); | |
| 145 | −}; | |
| 146 | 178 | |
| 147 | 179 | export const loadLocalControlPlaneState = ( |
| 148 | 180 | context: ResolvedLocalWorkspaceContext, |
| 149 | 181 | ): Effect.Effect<LocalControlPlaneState, LocalFileSystemError> => |
| 150 | − Effect.tryPromise({ | |
| 151 | − try: () => readStateFromDisk(context), | |
| 152 | − catch: mapFileSystemError( | |
| 153 | − localControlPlaneStatePath(context), | |
| 154 | − "read control plane state", | |
| 155 | − ), | |
| 156 | − }); | |
| 182 | + bindNodeFileSystem(readStateFromDisk(context)); | |
| 157 | 183 | |
| 158 | 184 | export const writeLocalControlPlaneState = (input: { |
| 159 | 185 | context: ResolvedLocalWorkspaceContext; |
| 160 | 186 | state: LocalControlPlaneState; |
| 161 | 187 | }): Effect.Effect<void, LocalFileSystemError> => |
| 162 | − Effect.tryPromise({ | |
| 163 | − try: () => writeStateToDisk(input.context, input.state), | |
| 164 | − catch: mapFileSystemError( | |
| 165 | − localControlPlaneStatePath(input.context), | |
| 166 | − "write control plane state", | |
| 167 | − ), | |
| 168 | − }); | |
| 188 | + bindNodeFileSystem(writeStateToDisk(input.context, input.state)); | |
| 169 | 189 | |
| 170 | 190 | const mergeById = <T extends { id: string }>( |
| 171 | 191 | current: readonly T[], |
| @@ -338,16 +358,21 @@ type StateMutationResult<A> = { · 166 unmodified lines | ||
| 338 | 358 | value: A; |
| 339 | 359 | }; |
| 340 | 360 | |
| 341 | −const createStateManager = (context: ResolvedLocalWorkspaceContext) => { | |
| 361 | +const createStateManager = ( | |
| 362 | + context: ResolvedLocalWorkspaceContext, | |
| 363 | + fileSystem: FileSystem.FileSystem, | |
| 364 | +) => { | |
| 342 | 365 | let cache: LocalControlPlaneState | null = null; |
| 343 | 366 | let mutationQueue: Promise<void> = Promise.resolve(); |
| 367 | + const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) => | |
| 368 | + Effect.runPromise(bindFileSystem(fileSystem, effect)); | |
| 344 | 369 | |
| 345 | 370 | const ensureLoaded = async (): Promise<LocalControlPlaneState> => { |
| 346 | 371 | if (cache !== null) { |
| 347 | 372 | return cache; |
| 348 | 373 | } |
| 349 | 374 | |
| 350 | − cache = await readStateFromDisk(context); | |
| 375 | + cache = await run(readStateFromDisk(context)); | |
| 351 | 376 | return cache; |
| 352 | 377 | }; |
| 353 | 378 | |
| @@ -381,7 +406,7 @@ const createStateManager = (context: ResolvedLocalWorkspaceContext) => { · 27 unmodified lines | ||
| 381 | 406 | const result = await operation(current); |
| 382 | 407 | cache = result.state; |
| 383 | 408 | value = result.value; |
| 384 | − await writeStateToDisk(context, cache); | |
| 409 | + await run(writeStateToDisk(context, cache)); | |
| 385 | 410 | } catch (cause) { |
| 386 | 411 | failure = cause; |
| 387 | 412 | } |
| @@ -409,8 +434,9 @@ const createStateManager = (context: ResolvedLocalWorkspaceContext) => { · 21 unmodified lines | ||
| 409 | 434 | |
| 410 | 435 | export const createLocalControlPlaneStore = ( |
| 411 | 436 | context: ResolvedLocalWorkspaceContext, |
| 437 | + fileSystem: FileSystem.FileSystem, | |
| 412 | 438 | ) => { |
| 413 | − const stateManager = createStateManager(context); | |
| 439 | + const stateManager = createStateManager(context, fileSystem); | |
| 414 | 440 | |
| 415 | 441 | return { |
| 416 | 442 | authArtifacts: { |
| @@ -1189,8 +1215,9 @@ export type LocalControlPlaneStore = ReturnType<typeof createLocalControlPlaneSt · 772 unmodified lines | ||
| 1189 | 1215 | |
| 1190 | 1216 | export const createLocalControlPlanePersistence = ( |
| 1191 | 1217 | context: ResolvedLocalWorkspaceContext, |
| 1218 | + fileSystem: FileSystem.FileSystem, | |
| 1192 | 1219 | ): LocalControlPlanePersistence => ({ |
| 1193 | − rows: createLocalControlPlaneStore(context), | |
| 1220 | + rows: createLocalControlPlaneStore(context, fileSystem), | |
| 1194 | 1221 | close: async () => {}, |
| 1195 | 1222 | }); |
| 1196 | 1223 | |
| @@ -1,6 +1,6 @@ | ||
| 1 | −import { existsSync, mkdtempSync } from "node:fs"; | |
| 2 | 1 | import { tmpdir } from "node:os"; |
| 3 | 2 | import { join } from "node:path"; |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 4 | 4 | import { NodeFileSystem } from "@effect/platform-node"; |
| 5 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| @@ -9,40 +9,53 @@ import { createControlPlaneRuntime } from "../index"; · 2 unmodified lines | ||
| 9 | 9 | import { getOrProvisionLocalInstallation } from "./installation"; |
| 10 | 10 | import { resolveLocalWorkspaceContext } from "./config"; |
| 11 | 11 | |
| 12 | −const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-")); | |
| 13 | −const TEST_HOME_CONFIG_PATH = join(TEST_WORKSPACE_ROOT, ".executor-home.jsonc"); | |
| 14 | −const TEST_HOME_STATE_DIRECTORY = join(TEST_WORKSPACE_ROOT, ".executor-home-state"); | |
| 15 | − | |
| 16 | 12 | const makeRuntime = Effect.acquireRelease( |
| 17 | − createControlPlaneRuntime({ | |
| 18 | − localDataDir: ":memory:", | |
| 19 | − workspaceRoot: TEST_WORKSPACE_ROOT, | |
| 20 | − homeConfigPath: TEST_HOME_CONFIG_PATH, | |
| 21 | − homeStateDirectory: TEST_HOME_STATE_DIRECTORY, | |
| 22 | − }), | |
| 23 | − (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), | |
| 13 | + Effect.gen(function* () { | |
| 14 | + const fs = yield* FileSystem.FileSystem; | |
| 15 | + const workspaceRoot = yield* fs.makeTempDirectory({ | |
| 16 | + directory: tmpdir(), | |
| 17 | + prefix: "executor-local-installation-", | |
| 18 | + }); | |
| 19 | + const homeConfigPath = join(workspaceRoot, ".executor-home.jsonc"); | |
| 20 | + const homeStateDirectory = join(workspaceRoot, ".executor-home-state"); | |
| 21 | + const runtime = yield* createControlPlaneRuntime({ | |
| 22 | + localDataDir: ":memory:", | |
| 23 | + workspaceRoot, | |
| 24 | + homeConfigPath, | |
| 25 | + homeStateDirectory, | |
| 26 | + }); | |
| 27 | + | |
| 28 | + return { | |
| 29 | + runtime, | |
| 30 | + workspaceRoot, | |
| 31 | + homeConfigPath, | |
| 32 | + homeStateDirectory, | |
| 33 | + }; | |
| 34 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 35 | + ({ runtime }) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), | |
| 24 | 36 | ); |
| 25 | 37 | |
| 26 | 38 | describe("local-installation", () => { |
| 27 | 39 | it.scoped("derives a stable local identity on first boot", () => |
| 28 | 40 | Effect.gen(function* () { |
| 29 | − const runtime = yield* makeRuntime; | |
| 41 | + const { runtime, workspaceRoot } = yield* makeRuntime; | |
| 30 | 42 | const installation = runtime.localInstallation; |
| 43 | + const fs = yield* FileSystem.FileSystem; | |
| 31 | 44 | |
| 32 | 45 | expect(installation.accountId).toBe("acc_local_default"); |
| 33 | 46 | expect(installation.workspaceId.startsWith("ws_local_")).toBe(true); |
| 34 | − expect(existsSync(join(TEST_WORKSPACE_ROOT, ".executor", "executor.jsonc"))).toBe(false); | |
| 35 | − }), | |
| 47 | + expect(yield* fs.exists(join(workspaceRoot, ".executor", "executor.jsonc"))).toBe(false); | |
| 48 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 36 | 49 | 60_000, |
| 37 | 50 | ); |
| 38 | 51 | |
| 39 | 52 | it.scoped("is idempotent when loading the default local installation", () => |
| 40 | 53 | Effect.gen(function* () { |
| 41 | − const runtime = yield* makeRuntime; | |
| 54 | + const { runtime, workspaceRoot, homeConfigPath, homeStateDirectory } = yield* makeRuntime; | |
| 42 | 55 | const context = yield* resolveLocalWorkspaceContext({ |
| 43 | − workspaceRoot: TEST_WORKSPACE_ROOT, | |
| 44 | − homeConfigPath: TEST_HOME_CONFIG_PATH, | |
| 45 | − homeStateDirectory: TEST_HOME_STATE_DIRECTORY, | |
| 56 | + workspaceRoot, | |
| 57 | + homeConfigPath, | |
| 58 | + homeStateDirectory, | |
| 46 | 59 | }); |
| 47 | 60 | |
| 48 | 61 | const first = runtime.localInstallation; |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | import { spawn } from "node:child_process"; |
| 3 | −import { promises as fs } from "node:fs"; | |
| 4 | −import { lstatSync, realpathSync } from "node:fs"; | |
| 5 | 3 | import { isAbsolute } from "node:path"; |
| 4 | +import { FileSystem } from "@effect/platform"; | |
| 5 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | 8 | type LocalConfigSecretProvider, |
| @@ -29,6 +29,9 @@ export const PARAMS_SECRET_PROVIDER_ID = "params"; · 20 unmodified lines | ||
| 29 | 29 | export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain"; |
| 30 | 30 | export const LOCAL_SECRET_PROVIDER_ID = "local"; |
| 31 | 31 | |
| 32 | +const toError = (cause: unknown): Error => | |
| 33 | + cause instanceof Error ? cause : new Error(String(cause)); | |
| 34 | + | |
| 32 | 35 | export type SecretStoreProviderId = |
| 33 | 36 | | typeof KEYCHAIN_SECRET_PROVIDER_ID |
| 34 | 37 | | typeof LOCAL_SECRET_PROVIDER_ID; |
| @@ -856,151 +859,191 @@ const createSecretMaterialProviderRuntime = (input: { · 821 unmodified lines | ||
| 856 | 859 | workspaceRoot: input.workspaceRoot ?? null, |
| 857 | 860 | }); |
| 858 | 861 | |
| 859 | −const isRegularFilePath = (path: string, allowSymlink: boolean): boolean => { | |
| 860 | − const stat = lstatSync(path); | |
| 861 | − if (stat.isSymbolicLink()) { | |
| 862 | − if (!allowSymlink) { | |
| 863 | − return false; | |
| 862 | +const isRegularFilePath = ( | |
| 863 | + path: string, | |
| 864 | + allowSymlink: boolean, | |
| 865 | +): Effect.Effect<boolean, Error, FileSystem.FileSystem> => | |
| 866 | + Effect.gen(function* () { | |
| 867 | + const fs = yield* FileSystem.FileSystem; | |
| 868 | + const stat = yield* fs.stat(path).pipe(Effect.mapError(toError)); | |
| 869 | + if (stat.type === "SymbolicLink") { | |
| 870 | + if (!allowSymlink) { | |
| 871 | + return false; | |
| 872 | + } | |
| 873 | + | |
| 874 | + const resolvedPath = yield* fs.realPath(path).pipe(Effect.mapError(toError)); | |
| 875 | + const targetStat = yield* fs.stat(resolvedPath).pipe(Effect.mapError(toError)); | |
| 876 | + return targetStat.type === "File"; | |
| 864 | 877 | } |
| 865 | − const targetStat = lstatSync(realpathSync(path)); | |
| 866 | − return targetStat.isFile(); | |
| 867 | − } | |
| 868 | − return stat.isFile(); | |
| 869 | −}; | |
| 870 | 878 | |
| 871 | −const ensureTrustedDir = (path: string, trustedDirs: readonly string[] | undefined): boolean => { | |
| 872 | − if (!trustedDirs || trustedDirs.length === 0) { | |
| 873 | − return true; | |
| 874 | − } | |
| 879 | + return stat.type === "File"; | |
| 880 | + }); | |
| 881 | + | |
| 882 | +const ensureTrustedDir = ( | |
| 883 | + path: string, | |
| 884 | + trustedDirs: readonly string[] | undefined, | |
| 885 | +): Effect.Effect<boolean, Error, FileSystem.FileSystem> => | |
| 886 | + Effect.gen(function* () { | |
| 887 | + if (!trustedDirs || trustedDirs.length === 0) { | |
| 888 | + return true; | |
| 889 | + } | |
| 890 | + | |
| 891 | + const fs = yield* FileSystem.FileSystem; | |
| 892 | + const real = yield* fs.realPath(path).pipe(Effect.mapError(toError)); | |
| 893 | + | |
| 894 | + for (const dir of trustedDirs) { | |
| 895 | + const trusted = yield* fs.realPath(dir).pipe(Effect.mapError(toError)); | |
| 896 | + if (real === trusted || real.startsWith(`${trusted}/`)) { | |
| 897 | + return true; | |
| 898 | + } | |
| 899 | + } | |
| 875 | 900 | |
| 876 | − const real = realpathSync(path); | |
| 877 | − return trustedDirs.some((dir) => { | |
| 878 | − const trusted = realpathSync(dir); | |
| 879 | − return real === trusted || real.startsWith(`${trusted}/`); | |
| 901 | + return false; | |
| 880 | 902 | }); |
| 881 | −}; | |
| 882 | 903 | |
| 883 | 904 | const readFileSecretValue = (input: { |
| 884 | 905 | provider: Extract<LocalConfigSecretProvider, { source: "file" }>; |
| 885 | 906 | ref: SecretRef; |
| 886 | 907 | workspaceRoot: string; |
| 887 | −}): Effect.Effect<string, Error, never> => | |
| 888 | − Effect.tryPromise({ | |
| 889 | − try: async () => { | |
| 890 | − const resolvedPath = resolveConfigRelativePath({ | |
| 891 | − path: input.provider.path, | |
| 892 | − workspaceRoot: input.workspaceRoot, | |
| 893 | − }); | |
| 894 | − const raw = await fs.readFile(resolvedPath, "utf8"); | |
| 895 | − const mode = input.provider.mode ?? "singleValue"; | |
| 896 | − if (mode === "singleValue") { | |
| 897 | − return raw.trim(); | |
| 898 | − } | |
| 908 | +}): Effect.Effect<string, Error, FileSystem.FileSystem> => | |
| 909 | + Effect.gen(function* () { | |
| 910 | + const fs = yield* FileSystem.FileSystem; | |
| 911 | + const resolvedPath = resolveConfigRelativePath({ | |
| 912 | + path: input.provider.path, | |
| 913 | + workspaceRoot: input.workspaceRoot, | |
| 914 | + }); | |
| 915 | + const raw = yield* fs.readFileString(resolvedPath, "utf8").pipe( | |
| 916 | + Effect.mapError(toError), | |
| 917 | + ); | |
| 918 | + const mode = input.provider.mode ?? "singleValue"; | |
| 919 | + if (mode === "singleValue") { | |
| 920 | + return raw.trim(); | |
| 921 | + } | |
| 899 | 922 | |
| 900 | − const parsed = JSON.parse(raw) as unknown; | |
| 901 | − if (input.ref.handle.startsWith("/")) { | |
| 902 | − const segments = input.ref.handle | |
| 903 | − .split("/") | |
| 904 | − .slice(1) | |
| 905 | − .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")); | |
| 906 | − let current: unknown = parsed; | |
| 907 | − for (const segment of segments) { | |
| 908 | − if (typeof current !== "object" || current === null || !(segment in current)) { | |
| 909 | − throw new Error(`Secret path not found in ${resolvedPath}: ${input.ref.handle}`); | |
| 923 | + return yield* Effect.try({ | |
| 924 | + try: () => { | |
| 925 | + const parsed = JSON.parse(raw) as unknown; | |
| 926 | + if (input.ref.handle.startsWith("/")) { | |
| 927 | + const segments = input.ref.handle | |
| 928 | + .split("/") | |
| 929 | + .slice(1) | |
| 930 | + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")); | |
| 931 | + let current: unknown = parsed; | |
| 932 | + for (const segment of segments) { | |
| 933 | + if (typeof current !== "object" || current === null || !(segment in current)) { | |
| 934 | + throw new Error(`Secret path not found in ${resolvedPath}: ${input.ref.handle}`); | |
| 935 | + } | |
| 936 | + current = (current as Record<string, unknown>)[segment]; | |
| 910 | 937 | } |
| 911 | − current = (current as Record<string, unknown>)[segment]; | |
| 912 | − } | |
| 913 | − if (typeof current !== "string" || current.trim().length === 0) { | |
| 914 | − throw new Error(`Secret path did not resolve to a string: ${input.ref.handle}`); | |
| 938 | + if (typeof current !== "string" || current.trim().length === 0) { | |
| 939 | + throw new Error(`Secret path did not resolve to a string: ${input.ref.handle}`); | |
| 940 | + } | |
| 941 | + return current; | |
| 915 | 942 | } |
| 916 | − return current; | |
| 917 | − } | |
| 918 | 943 | |
| 919 | − if (typeof parsed !== "object" || parsed === null) { | |
| 920 | − throw new Error(`JSON secret provider must resolve to an object: ${resolvedPath}`); | |
| 921 | − } | |
| 922 | − const value = (parsed as Record<string, unknown>)[input.ref.handle]; | |
| 923 | − if (typeof value !== "string" || value.trim().length === 0) { | |
| 924 | − throw new Error(`Secret key not found in ${resolvedPath}: ${input.ref.handle}`); | |
| 925 | − } | |
| 926 | − return value; | |
| 927 | − }, | |
| 928 | − catch: (cause) => | |
| 929 | − cause instanceof Error ? cause : new Error(String(cause)), | |
| 944 | + if (typeof parsed !== "object" || parsed === null) { | |
| 945 | + throw new Error(`JSON secret provider must resolve to an object: ${resolvedPath}`); | |
| 946 | + } | |
| 947 | + const value = (parsed as Record<string, unknown>)[input.ref.handle]; | |
| 948 | + if (typeof value !== "string" || value.trim().length === 0) { | |
| 949 | + throw new Error(`Secret key not found in ${resolvedPath}: ${input.ref.handle}`); | |
| 950 | + } | |
| 951 | + return value; | |
| 952 | + }, | |
| 953 | + catch: toError, | |
| 954 | + }); | |
| 930 | 955 | }); |
| 931 | 956 | |
| 932 | 957 | const resolveConfiguredSecretProvider = (input: { |
| 933 | 958 | ref: SecretRef; |
| 934 | 959 | runtime: SecretMaterialProviderRuntime; |
| 935 | −}): Effect.Effect<string, Error, never> => { | |
| 936 | − const providerAlias = fromConfigSecretProviderId(input.ref.providerId); | |
| 937 | − if (providerAlias === null) { | |
| 938 | − return Effect.fail(runtimeEffectError("local/secret-material-providers", `Unsupported secret provider: ${input.ref.providerId}`)); | |
| 939 | − } | |
| 960 | +}): Effect.Effect<string, Error, FileSystem.FileSystem> => | |
| 961 | + Effect.gen(function* () { | |
| 962 | + const providerAlias = fromConfigSecretProviderId(input.ref.providerId); | |
| 963 | + if (providerAlias === null) { | |
| 964 | + return yield* runtimeEffectError( | |
| 965 | + "local/secret-material-providers", | |
| 966 | + `Unsupported secret provider: ${input.ref.providerId}`, | |
| 967 | + ); | |
| 968 | + } | |
| 940 | 969 | |
| 941 | − const provider = input.runtime.localConfig?.secrets?.providers?.[providerAlias]; | |
| 942 | − if (!provider) { | |
| 943 | − return Effect.fail( | |
| 944 | − runtimeEffectError("local/secret-material-providers", `Config secret provider "${providerAlias}" is not configured`), | |
| 945 | − ); | |
| 946 | − } | |
| 947 | − if (input.runtime.workspaceRoot === null) { | |
| 948 | − return Effect.fail( | |
| 949 | − runtimeEffectError("local/secret-material-providers", `Config secret provider "${providerAlias}" requires a workspace root`), | |
| 950 | − ); | |
| 951 | − } | |
| 970 | + const provider = input.runtime.localConfig?.secrets?.providers?.[providerAlias]; | |
| 971 | + if (!provider) { | |
| 972 | + return yield* runtimeEffectError( | |
| 973 | + "local/secret-material-providers", | |
| 974 | + `Config secret provider "${providerAlias}" is not configured`, | |
| 975 | + ); | |
| 976 | + } | |
| 977 | + if (input.runtime.workspaceRoot === null) { | |
| 978 | + return yield* runtimeEffectError( | |
| 979 | + "local/secret-material-providers", | |
| 980 | + `Config secret provider "${providerAlias}" requires a workspace root`, | |
| 981 | + ); | |
| 982 | + } | |
| 952 | 983 | |
| 953 | − if (provider.source === "env") { | |
| 954 | − const value = ensureNonEmptyString(input.runtime.env[input.ref.handle]); | |
| 955 | − return value === null | |
| 956 | − ? Effect.fail(runtimeEffectError("local/secret-material-providers", `Environment variable ${input.ref.handle} is not set`)) | |
| 957 | − : Effect.succeed(value); | |
| 958 | − } | |
| 984 | + if (provider.source === "env") { | |
| 985 | + const value = ensureNonEmptyString(input.runtime.env[input.ref.handle]); | |
| 986 | + if (value === null) { | |
| 987 | + return yield* runtimeEffectError( | |
| 988 | + "local/secret-material-providers", | |
| 989 | + `Environment variable ${input.ref.handle} is not set`, | |
| 990 | + ); | |
| 991 | + } | |
| 992 | + return value; | |
| 993 | + } | |
| 959 | 994 | |
| 960 | − if (provider.source === "file") { | |
| 961 | − return readFileSecretValue({ | |
| 962 | − provider, | |
| 963 | − ref: input.ref, | |
| 964 | − workspaceRoot: input.runtime.workspaceRoot, | |
| 965 | − }); | |
| 966 | − } | |
| 995 | + if (provider.source === "file") { | |
| 996 | + return yield* readFileSecretValue({ | |
| 997 | + provider, | |
| 998 | + ref: input.ref, | |
| 999 | + workspaceRoot: input.runtime.workspaceRoot, | |
| 1000 | + }); | |
| 1001 | + } | |
| 967 | 1002 | |
| 968 | − const command = provider.command.trim(); | |
| 969 | − if (!isAbsolute(command)) { | |
| 970 | − return Effect.fail( | |
| 971 | − runtimeEffectError("local/secret-material-providers", `Exec secret provider command must be absolute: ${command}`), | |
| 972 | − ); | |
| 973 | − } | |
| 974 | − if (!isRegularFilePath(command, provider.allowSymlinkCommand ?? false)) { | |
| 975 | − return Effect.fail( | |
| 976 | − runtimeEffectError("local/secret-material-providers", `Exec secret provider command is not an allowed regular file: ${command}`), | |
| 977 | − ); | |
| 978 | − } | |
| 979 | − if (!ensureTrustedDir(command, provider.trustedDirs)) { | |
| 980 | − return Effect.fail( | |
| 981 | − runtimeEffectError("local/secret-material-providers", `Exec secret provider command is outside trustedDirs: ${command}`), | |
| 1003 | + const command = provider.command.trim(); | |
| 1004 | + if (!isAbsolute(command)) { | |
| 1005 | + return yield* runtimeEffectError( | |
| 1006 | + "local/secret-material-providers", | |
| 1007 | + `Exec secret provider command must be absolute: ${command}`, | |
| 1008 | + ); | |
| 1009 | + } | |
| 1010 | + const isRegularCommand = yield* isRegularFilePath( | |
| 1011 | + command, | |
| 1012 | + provider.allowSymlinkCommand ?? false, | |
| 982 | 1013 | ); |
| 983 | − } | |
| 1014 | + if (!isRegularCommand) { | |
| 1015 | + return yield* runtimeEffectError( | |
| 1016 | + "local/secret-material-providers", | |
| 1017 | + `Exec secret provider command is not an allowed regular file: ${command}`, | |
| 1018 | + ); | |
| 1019 | + } | |
| 1020 | + const isTrustedCommand = yield* ensureTrustedDir(command, provider.trustedDirs); | |
| 1021 | + if (!isTrustedCommand) { | |
| 1022 | + return yield* runtimeEffectError( | |
| 1023 | + "local/secret-material-providers", | |
| 1024 | + `Exec secret provider command is outside trustedDirs: ${command}`, | |
| 1025 | + ); | |
| 1026 | + } | |
| 984 | 1027 | |
| 985 | − return runCommand({ | |
| 986 | − command, | |
| 987 | − args: [...(provider.args ?? []), input.ref.handle], | |
| 988 | − env: { | |
| 989 | − ...input.runtime.env, | |
| 990 | − ...provider.env, | |
| 991 | − }, | |
| 992 | − operation: `config-secret.get:${providerAlias}`, | |
| 993 | − }).pipe( | |
| 994 | − Effect.flatMap((result) => | |
| 995 | − ensureCommandSuccess({ | |
| 996 | − result, | |
| 997 | − operation: `config-secret.get:${providerAlias}`, | |
| 998 | − message: "Failed resolving configured exec secret", | |
| 999 | − }), | |
| 1000 | − ), | |
| 1001 | − Effect.map((result) => result.stdout.trimEnd()), | |
| 1002 | − ); | |
| 1003 | −}; | |
| 1028 | + return yield* runCommand({ | |
| 1029 | + command, | |
| 1030 | + args: [...(provider.args ?? []), input.ref.handle], | |
| 1031 | + env: { | |
| 1032 | + ...input.runtime.env, | |
| 1033 | + ...provider.env, | |
| 1034 | + }, | |
| 1035 | + operation: `config-secret.get:${providerAlias}`, | |
| 1036 | + }).pipe( | |
| 1037 | + Effect.flatMap((result) => | |
| 1038 | + ensureCommandSuccess({ | |
| 1039 | + result, | |
| 1040 | + operation: `config-secret.get:${providerAlias}`, | |
| 1041 | + message: "Failed resolving configured exec secret", | |
| 1042 | + }), | |
| 1043 | + ), | |
| 1044 | + Effect.map((result) => result.stdout.trimEnd()), | |
| 1045 | + ); | |
| 1046 | + }); | |
| 1004 | 1047 | |
| 1005 | 1048 | export const createDefaultSecretMaterialResolver = (input: { |
| 1006 | 1049 | rows: ControlPlaneStoreShape; |
| @@ -1037,7 +1080,7 @@ export const createDefaultSecretMaterialResolver = (input: { · 30 unmodified lines | ||
| 1037 | 1080 | context: context ?? {}, |
| 1038 | 1081 | runtime, |
| 1039 | 1082 | }); |
| 1040 | − }); | |
| 1083 | + }).pipe(Effect.provide(NodeFileSystem.layer)); | |
| 1041 | 1084 | }; |
| 1042 | 1085 | |
| 1043 | 1086 | export const createDefaultSecretMaterialStorer = (input: { |
| @@ -1,6 +1,6 @@ | ||
| 1 | −import { existsSync, mkdtempSync, readFileSync } from "node:fs"; | |
| 2 | 1 | import { tmpdir } from "node:os"; |
| 3 | 2 | import { join } from "node:path"; |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 4 | 4 | import { NodeFileSystem } from "@effect/platform-node"; |
| 5 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| @@ -17,20 +17,30 @@ import { createCatalogImportMetadata } from "@executor/source-core"; · 10 unmodified lines | ||
| 17 | 17 | import { createGraphqlCatalogFragment } from "@executor/source-graphql"; |
| 18 | 18 | import { createOpenApiCatalogFragment } from "@executor/source-openapi"; |
| 19 | 19 | |
| 20 | −const makeContext = (): ResolvedLocalWorkspaceContext => { | |
| 21 | − const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-artifacts-")); | |
| 22 | − return { | |
| 23 | − cwd: workspaceRoot, | |
| 24 | − workspaceRoot, | |
| 25 | − workspaceName: "executor-artifacts", | |
| 26 | − configDirectory: join(workspaceRoot, ".executor"), | |
| 27 | − projectConfigPath: join(workspaceRoot, ".executor", "executor.jsonc"), | |
| 28 | − homeConfigPath: join(workspaceRoot, ".executor-home.jsonc"), | |
| 29 | − homeStateDirectory: join(workspaceRoot, ".executor-home-state"), | |
| 30 | − artifactsDirectory: join(workspaceRoot, ".executor", "artifacts"), | |
| 31 | − stateDirectory: join(workspaceRoot, ".executor", "state"), | |
| 32 | − }; | |
| 33 | −}; | |
| 20 | +const makeContext = (): Effect.Effect< | |
| 21 | + ResolvedLocalWorkspaceContext, | |
| 22 | + never, | |
| 23 | + FileSystem.FileSystem | |
| 24 | +> => | |
| 25 | + Effect.gen(function* () { | |
| 26 | + const fs = yield* FileSystem.FileSystem; | |
| 27 | + const workspaceRoot = yield* fs.makeTempDirectory({ | |
| 28 | + directory: tmpdir(), | |
| 29 | + prefix: "executor-artifacts-", | |
| 30 | + }).pipe(Effect.orDie); | |
| 31 | + | |
| 32 | + return { | |
| 33 | + cwd: workspaceRoot, | |
| 34 | + workspaceRoot, | |
| 35 | + workspaceName: "executor-artifacts", | |
| 36 | + configDirectory: join(workspaceRoot, ".executor"), | |
| 37 | + projectConfigPath: join(workspaceRoot, ".executor", "executor.jsonc"), | |
| 38 | + homeConfigPath: join(workspaceRoot, ".executor-home.jsonc"), | |
| 39 | + homeStateDirectory: join(workspaceRoot, ".executor-home-state"), | |
| 40 | + artifactsDirectory: join(workspaceRoot, ".executor", "artifacts"), | |
| 41 | + stateDirectory: join(workspaceRoot, ".executor", "state"), | |
| 42 | + }; | |
| 43 | + }); | |
| 34 | 44 | |
| 35 | 45 | const makeSource = (): Source => ({ |
| 36 | 46 | id: SourceIdSchema.make("src_test"), |
| @@ -148,7 +158,8 @@ const makeGraphqlArtifact = () => { · 111 unmodified lines | ||
| 148 | 158 | describe("local-source-artifacts", () => { |
| 149 | 159 | it.effect("writes canonical uncompressed artifacts and reads them back", () => |
| 150 | 160 | Effect.gen(function* () { |
| 151 | − const context = makeContext(); | |
| 161 | + const fs = yield* FileSystem.FileSystem; | |
| 162 | + const context = yield* makeContext(); | |
| 152 | 163 | const artifact = makeArtifact(); |
| 153 | 164 | const path = join(context.artifactsDirectory, "sources", "src_test.json"); |
| 154 | 165 | const rawDocumentPath = join( |
| @@ -165,11 +176,11 @@ describe("local-source-artifacts", () => { · 10 unmodified lines | ||
| 165 | 176 | artifact, |
| 166 | 177 | }); |
| 167 | 178 | |
| 168 | − expect(existsSync(path)).toBe(true); | |
| 169 | − expect(readFileSync(path, "utf8").startsWith("{")).toBe(true); | |
| 170 | − expect(existsSync(rawDocumentPath)).toBe(true); | |
| 179 | + expect(yield* fs.exists(path)).toBe(true); | |
| 180 | + expect((yield* fs.readFileString(path, "utf8")).startsWith("{")).toBe(true); | |
| 181 | + expect(yield* fs.exists(rawDocumentPath)).toBe(true); | |
| 171 | 182 | |
| 172 | − const persistedArtifact = JSON.parse(readFileSync(path, "utf8")); | |
| 183 | + const persistedArtifact = JSON.parse(yield* fs.readFileString(path, "utf8")); | |
| 173 | 184 | const persistedDocument = Object.values( |
| 174 | 185 | persistedArtifact.snapshot.catalog.documents, |
| 175 | 186 | )[0] as { |
| @@ -220,7 +231,7 @@ describe("local-source-artifacts", () => { · 44 unmodified lines | ||
| 220 | 231 | "preserves GraphQL execution metadata across a write/read round-trip", |
| 221 | 232 | () => |
| 222 | 233 | Effect.gen(function* () { |
| 223 | − const context = makeContext(); | |
| 234 | + const context = yield* makeContext(); | |
| 224 | 235 | const artifact = makeGraphqlArtifact(); |
| 225 | 236 | |
| 226 | 237 | yield* writeLocalSourceArtifact({ |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { createHash } from "node:crypto"; |
| 2 | −import { existsSync } from "node:fs"; | |
| 3 | −import { dirname, extname, join, relative, resolve, sep } from "node:path"; | |
| 4 | −import { fileURLToPath, pathToFileURL } from "node:url"; | |
| 2 | +import { createRequire } from "node:module"; | |
| 3 | +import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; | |
| 4 | +import { pathToFileURL } from "node:url"; | |
| 5 | 5 | import { FileSystem } from "@effect/platform"; |
| 6 | 6 | import { |
| 7 | 7 | createToolCatalogFromTools, |
| @@ -332,12 +332,12 @@ const transpileSourceFile = (input: { · 324 unmodified lines | ||
| 332 | 332 | }); |
| 333 | 333 | |
| 334 | 334 | const resolveExecutorNodeModulesDirectory = (): string | null => { |
| 335 | − let current = dirname(fileURLToPath(import.meta.url)); | |
| 335 | + const require = createRequire(import.meta.url); | |
| 336 | + let current = dirname(require.resolve("typescript")); | |
| 336 | 337 | |
| 337 | 338 | while (true) { |
| 338 | − const candidate = join(current, "node_modules"); | |
| 339 | − if (existsSync(candidate)) { | |
| 340 | − return candidate; | |
| 339 | + if (basename(current) === "node_modules") { | |
| 340 | + return current; | |
| 341 | 341 | } |
| 342 | 342 | |
| 343 | 343 | const parent = dirname(current); |
| @@ -1,13 +1,15 @@ | ||
| 1 | −import { readFileSync } from "node:fs"; | |
| 2 | 1 | import { createServer } from "node:http"; |
| 2 | +import { fileURLToPath } from "node:url"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | + FileSystem, | |
| 5 | 6 | HttpApi, |
| 6 | 7 | HttpApiEndpoint, |
| 7 | 8 | HttpApiGroup, |
| 8 | 9 | HttpApiSchema, |
| 9 | 10 | OpenApi, |
| 10 | 11 | } from "@effect/platform"; |
| 12 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 11 | 13 | import { |
| 12 | 14 | buildOpenApiToolPresentation, |
| 13 | 15 | compileOpenApiToolDefinitions, |
| @@ -57,8 +59,16 @@ import { createOpenApiCatalogSnapshot } from "@executor/source-openapi"; · 43 unmodified lines | ||
| 57 | 59 | |
| 58 | 60 | const FIXTURE_WORKSPACE_ID = WorkspaceIdSchema.make("ws_source_fixture_matrix"); |
| 59 | 61 | |
| 60 | −const readFixture = (name: string): string => | |
| 61 | − readFileSync(new URL(`../fixtures/${name}`, import.meta.url), "utf8"); | |
| 62 | +const readFixture = (name: string) => | |
| 63 | + FileSystem.FileSystem.pipe( | |
| 64 | + Effect.flatMap((fs) => | |
| 65 | + fs.readFileString( | |
| 66 | + fileURLToPath(new URL(`../fixtures/${name}`, import.meta.url)), | |
| 67 | + "utf8", | |
| 68 | + ) | |
| 69 | + ), | |
| 70 | + Effect.provide(NodeFileSystem.layer), | |
| 71 | + ); | |
| 62 | 72 | |
| 63 | 73 | const makeSource = (input: { |
| 64 | 74 | id: string; |
| @@ -324,7 +334,7 @@ describe("source adapter fixture matrix", () => { · 259 unmodified lines | ||
| 324 | 334 | "imports the raw recorded Vercel OpenAPI spec into IR and discover projections", |
| 325 | 335 | () => |
| 326 | 336 | Effect.gen(function* () { |
| 327 | − const specText = readFixture("vercel-openapi.json"); | |
| 337 | + const specText = yield* readFixture("vercel-openapi.json"); | |
| 328 | 338 | const source = makeSource({ |
| 329 | 339 | id: "src_vercel_fixture", |
| 330 | 340 | name: "Vercel", |
| @@ -393,7 +403,7 @@ describe("source adapter fixture matrix", () => { · 62 unmodified lines | ||
| 393 | 403 | "imports the raw recorded Neon OpenAPI spec into IR with resolved request body schemas", |
| 394 | 404 | () => |
| 395 | 405 | Effect.gen(function* () { |
| 396 | − const specText = readFixture("neon-openapi.json"); | |
| 406 | + const specText = yield* readFixture("neon-openapi.json"); | |
| 397 | 407 | const source = makeSource({ |
| 398 | 408 | id: "src_neon_fixture", |
| 399 | 409 | name: "Neon API", |
| @@ -935,7 +945,7 @@ describe("source adapter fixture matrix", () => { · 535 unmodified lines | ||
| 935 | 945 | "imports the raw recorded Google Sheets discovery document into IR and discover projections", |
| 936 | 946 | () => |
| 937 | 947 | Effect.gen(function* () { |
| 938 | − const documentText = readFixture("google-sheets-discovery.json"); | |
| 948 | + const documentText = yield* readFixture("google-sheets-discovery.json"); | |
| 939 | 949 | const source = makeSource({ |
| 940 | 950 | id: "src_google_sheets_fixture", |
| 941 | 951 | name: "Google Sheets", |
| @@ -1008,7 +1018,7 @@ describe("source adapter fixture matrix", () => { · 66 unmodified lines | ||
| 1008 | 1018 | "projects every raw recorded Google Sheets discovery method into schemas and capabilities", |
| 1009 | 1019 | () => |
| 1010 | 1020 | Effect.gen(function* () { |
| 1011 | − const documentText = readFixture("google-sheets-discovery.json"); | |
| 1021 | + const documentText = yield* readFixture("google-sheets-discovery.json"); | |
| 1012 | 1022 | const source = makeSource({ |
| 1013 | 1023 | id: "src_google_sheets_coverage", |
| 1014 | 1024 | name: "Google Sheets", |
| @@ -1070,7 +1080,7 @@ describe("source adapter fixture matrix", () => { · 55 unmodified lines | ||
| 1070 | 1080 | "imports the raw recorded Linear GraphQL introspection dump into IR and resolves nested input refs", |
| 1071 | 1081 | () => |
| 1072 | 1082 | Effect.gen(function* () { |
| 1073 | − const documentText = readFixture("linear-introspection.json"); | |
| 1083 | + const documentText = yield* readFixture("linear-introspection.json"); | |
| 1074 | 1084 | const source = makeSource({ |
| 1075 | 1085 | id: "src_linear_fixture", |
| 1076 | 1086 | name: "Linear GraphQL", |
| @@ -1370,7 +1380,7 @@ describe("source adapter fixture matrix", () => { · 293 unmodified lines | ||
| 1370 | 1380 | "projects every raw recorded Linear GraphQL tool into schemas and capabilities", |
| 1371 | 1381 | () => |
| 1372 | 1382 | Effect.gen(function* () { |
| 1373 | − const documentText = readFixture("linear-introspection.json"); | |
| 1383 | + const documentText = yield* readFixture("linear-introspection.json"); | |
| 1374 | 1384 | const source = makeSource({ |
| 1375 | 1385 | id: "src_linear_coverage", |
| 1376 | 1386 | name: "Linear GraphQL", |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | createSdkMcpConnector, |
| 3 | 3 | discoverMcpToolsFromConnector, |
| 4 | + isMcpStdioTransport, | |
| 4 | 5 | type McpDiscoveryElicitationContext, |
| 5 | 6 | } from "@executor/source-mcp"; |
| 6 | 7 | import { |
| @@ -125,6 +126,73 @@ const defaultNamespaceFromName = (name: string): string => { · 118 unmodified lines | ||
| 125 | 126 | return normalized.length > 0 ? normalized : "source"; |
| 126 | 127 | }; |
| 127 | 128 | |
| 129 | +const defaultSourceNameFromCommand = (command: string): string => { | |
| 130 | + const segments = command | |
| 131 | + .split(/[\\/]+/) | |
| 132 | + .map((segment) => segment.trim()) | |
| 133 | + .filter((segment) => segment.length > 0); | |
| 134 | + | |
| 135 | + return segments[segments.length - 1] ?? command; | |
| 136 | +}; | |
| 137 | + | |
| 138 | +const normalizeStringArray = ( | |
| 139 | + value: ReadonlyArray<string> | null | undefined, | |
| 140 | +): string[] | null => { | |
| 141 | + if (!value || value.length === 0) { | |
| 142 | + return null; | |
| 143 | + } | |
| 144 | + | |
| 145 | + const normalized = value | |
| 146 | + .map((entry) => entry.trim()) | |
| 147 | + .filter((entry) => entry.length > 0); | |
| 148 | + | |
| 149 | + return normalized.length > 0 ? normalized : null; | |
| 150 | +}; | |
| 151 | + | |
| 152 | +const slugifySourceLabel = (value: string): string => { | |
| 153 | + const normalized = value | |
| 154 | + .trim() | |
| 155 | + .toLowerCase() | |
| 156 | + .replace(/[^a-z0-9]+/g, "-") | |
| 157 | + .replace(/^-+|-+$/g, ""); | |
| 158 | + | |
| 159 | + return normalized.length > 0 ? normalized : "mcp"; | |
| 160 | +}; | |
| 161 | + | |
| 162 | +const createSyntheticMcpStdioEndpoint = (input: { | |
| 163 | + endpoint?: string | null; | |
| 164 | + name?: string | null; | |
| 165 | + command?: string | null; | |
| 166 | +}): string => { | |
| 167 | + const label = | |
| 168 | + trimOrNull(input.name) | |
| 169 | + ?? trimOrNull(input.endpoint) | |
| 170 | + ?? trimOrNull(input.command) | |
| 171 | + ?? "mcp"; | |
| 172 | + | |
| 173 | + return `stdio://local/${slugifySourceLabel(label)}`; | |
| 174 | +}; | |
| 175 | + | |
| 176 | +const normalizeMcpEndpoint = (input: { | |
| 177 | + endpoint?: string | null; | |
| 178 | + transport?: SourceTransport | null; | |
| 179 | + command?: string | null; | |
| 180 | + name?: string | null; | |
| 181 | +}): string => { | |
| 182 | + if (isMcpStdioTransport({ transport: input.transport ?? undefined, command: input.command ?? undefined })) { | |
| 183 | + return normalizeEndpoint( | |
| 184 | + trimOrNull(input.endpoint) ?? createSyntheticMcpStdioEndpoint(input), | |
| 185 | + ); | |
| 186 | + } | |
| 187 | + | |
| 188 | + const endpoint = trimOrNull(input.endpoint); | |
| 189 | + if (endpoint === null) { | |
| 190 | + throw new Error("Endpoint is required."); | |
| 191 | + } | |
| 192 | + | |
| 193 | + return normalizeEndpoint(endpoint); | |
| 194 | +}; | |
| 195 | + | |
| 128 | 196 | const resolveSourceCredentialOauthCompleteUrl = (input: { |
| 129 | 197 | baseUrl: string; |
| 130 | 198 | workspaceId: WorkspaceId; |
| @@ -152,10 +220,10 @@ const resolveWorkspaceProviderOauthCompleteUrl = (input: { · 21 unmodified lines | ||
| 152 | 220 | input.baseUrl, |
| 153 | 221 | ).toString(); |
| 154 | 222 | |
| 155 | −const normalizeEndpoint = (endpoint: string): string => { | |
| 223 | +function normalizeEndpoint(endpoint: string): string { | |
| 156 | 224 | const url = new URL(endpoint.trim()); |
| 157 | 225 | return url.toString(); |
| 158 | −}; | |
| 226 | +} | |
| 159 | 227 | |
| 160 | 228 | const defaultGoogleDiscoveryUrl = (service: string, version: string): string => |
| 161 | 229 | `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`; |
| @@ -226,6 +294,10 @@ const probeMcpSourceWithoutAuth = ( · 64 unmodified lines | ||
| 226 | 294 | transport: bindingState.transport ?? undefined, |
| 227 | 295 | queryParams: bindingState.queryParams ?? undefined, |
| 228 | 296 | headers: bindingState.headers ?? undefined, |
| 297 | + command: bindingState.command ?? undefined, | |
| 298 | + args: bindingState.args ?? undefined, | |
| 299 | + env: bindingState.env ?? undefined, | |
| 300 | + cwd: bindingState.cwd ?? undefined, | |
| 229 | 301 | }), |
| 230 | 302 | catch: (cause) => |
| 231 | 303 | cause instanceof Error ? cause : new Error(String(cause)), |
| @@ -497,9 +569,16 @@ export type ExecutorAddSourceInput = · 265 unmodified lines | ||
| 497 | 569 | actorAccountId?: AccountId | null; |
| 498 | 570 | executionId: SourceAuthSession["executionId"]; |
| 499 | 571 | interactionId: SourceAuthSession["interactionId"]; |
| 500 | − endpoint: string; | |
| 572 | + endpoint?: string | null; | |
| 501 | 573 | name?: string | null; |
| 502 | 574 | namespace?: string | null; |
| 575 | + transport?: SourceTransport | null; | |
| 576 | + queryParams?: StringMap | null; | |
| 577 | + headers?: StringMap | null; | |
| 578 | + command?: string | null; | |
| 579 | + args?: ReadonlyArray<string> | null; | |
| 580 | + env?: StringMap | null; | |
| 581 | + cwd?: string | null; | |
| 503 | 582 | } |
| 504 | 583 | | { |
| 505 | 584 | kind: "openapi"; |
| @@ -566,13 +645,17 @@ export type ConnectMcpSourceInput = { · 60 unmodified lines | ||
| 566 | 645 | workspaceId: WorkspaceId; |
| 567 | 646 | actorAccountId?: AccountId | null; |
| 568 | 647 | sourceId?: Source["id"] | null; |
| 569 | − endpoint: string; | |
| 648 | + endpoint?: string | null; | |
| 570 | 649 | name?: string | null; |
| 571 | 650 | namespace?: string | null; |
| 572 | 651 | enabled?: boolean; |
| 573 | − transport?: SourceTransport; | |
| 652 | + transport?: SourceTransport | null; | |
| 574 | 653 | queryParams?: StringMap | null; |
| 575 | 654 | headers?: StringMap | null; |
| 655 | + command?: string | null; | |
| 656 | + args?: ReadonlyArray<string> | null; | |
| 657 | + env?: StringMap | null; | |
| 658 | + cwd?: string | null; | |
| 576 | 659 | baseUrl?: string | null; |
| 577 | 660 | }; |
| 578 | 661 | |
| @@ -1549,18 +1632,21 @@ const connectMcpSourceInternal = (input: { · 970 unmodified lines | ||
| 1549 | 1632 | sourceId?: Source["id"] | null; |
| 1550 | 1633 | executionId?: SourceAuthSession["executionId"]; |
| 1551 | 1634 | interactionId?: SourceAuthSession["interactionId"]; |
| 1552 | − endpoint: string; | |
| 1635 | + endpoint?: string | null; | |
| 1553 | 1636 | name?: string | null; |
| 1554 | 1637 | namespace?: string | null; |
| 1555 | 1638 | enabled?: boolean; |
| 1556 | − transport?: SourceTransport; | |
| 1639 | + transport?: SourceTransport | null; | |
| 1557 | 1640 | queryParams?: StringMap | null; |
| 1558 | 1641 | headers?: StringMap | null; |
| 1642 | + command?: string | null; | |
| 1643 | + args?: ReadonlyArray<string> | null; | |
| 1644 | + env?: StringMap | null; | |
| 1645 | + cwd?: string | null; | |
| 1559 | 1646 | mcpDiscoveryElicitation?: McpDiscoveryElicitationContext; |
| 1560 | 1647 | resolveSecretMaterial: ResolveSecretMaterial; |
| 1561 | 1648 | }): Effect.Effect<McpSourceConnectResult, Error, WorkspaceStorageServices> => |
| 1562 | 1649 | Effect.gen(function* () { |
| 1563 | − const normalizedEndpoint = normalizeEndpoint(input.endpoint); | |
| 1564 | 1650 | const existing = yield* ( |
| 1565 | 1651 | input.sourceId |
| 1566 | 1652 | ? input.sourceStore.loadSourceById({ |
| @@ -1587,21 +1673,57 @@ const connectMcpSourceInternal = (input: { · 20 unmodified lines | ||
| 1587 | 1673 | ) |
| 1588 | 1674 | ); |
| 1589 | 1675 | |
| 1676 | + const existingBinding = existing | |
| 1677 | + ? yield* sourceBindingStateFromSource(existing) | |
| 1678 | + : null; | |
| 1679 | + const chosenCommand = | |
| 1680 | + input.command !== undefined ? trimOrNull(input.command) : (existingBinding?.command ?? null); | |
| 1681 | + const chosenTransport = input.transport !== undefined && input.transport !== null | |
| 1682 | + ? input.transport | |
| 1683 | + : isMcpStdioTransport({ | |
| 1684 | + transport: existingBinding?.transport ?? undefined, | |
| 1685 | + command: chosenCommand ?? undefined, | |
| 1686 | + }) | |
| 1687 | + ? "stdio" | |
| 1688 | + : (existingBinding?.transport ?? "auto"); | |
| 1689 | + if (chosenTransport === "stdio" && chosenCommand === null) { | |
| 1690 | + return yield* runtimeEffectError("sources/source-auth-service", "MCP stdio transport requires a command"); | |
| 1691 | + } | |
| 1692 | + const normalizedEndpoint = normalizeMcpEndpoint({ | |
| 1693 | + endpoint: input.endpoint ?? existing?.endpoint ?? null, | |
| 1694 | + transport: chosenTransport, | |
| 1695 | + command: chosenCommand, | |
| 1696 | + name: input.name ?? existing?.name ?? null, | |
| 1697 | + }); | |
| 1590 | 1698 | const chosenName = |
| 1591 | − trimOrNull(input.name) ?? existing?.name ?? defaultSourceNameFromEndpoint(normalizedEndpoint); | |
| 1699 | + trimOrNull(input.name) | |
| 1700 | + ?? existing?.name | |
| 1701 | + ?? (chosenTransport === "stdio" && chosenCommand !== null | |
| 1702 | + ? defaultSourceNameFromCommand(chosenCommand) | |
| 1703 | + : defaultSourceNameFromEndpoint(normalizedEndpoint)); | |
| 1592 | 1704 | const chosenNamespace = |
| 1593 | 1705 | trimOrNull(input.namespace) |
| 1594 | 1706 | ?? existing?.namespace |
| 1595 | 1707 | ?? defaultNamespaceFromName(chosenName); |
| 1596 | 1708 | const chosenEnabled = input.enabled ?? existing?.enabled ?? true; |
| 1597 | − const existingBinding = existing | |
| 1598 | − ? yield* sourceBindingStateFromSource(existing) | |
| 1599 | − : null; | |
| 1600 | − const chosenTransport = input.transport ?? existingBinding?.transport ?? "auto"; | |
| 1601 | 1709 | const chosenQueryParams = |
| 1602 | − input.queryParams !== undefined ? input.queryParams : (existingBinding?.queryParams ?? null); | |
| 1710 | + chosenTransport === "stdio" | |
| 1711 | + ? null | |
| 1712 | + : input.queryParams !== undefined | |
| 1713 | + ? input.queryParams | |
| 1714 | + : (existingBinding?.queryParams ?? null); | |
| 1603 | 1715 | const chosenHeaders = |
| 1604 | − input.headers !== undefined ? input.headers : (existingBinding?.headers ?? null); | |
| 1716 | + chosenTransport === "stdio" | |
| 1717 | + ? null | |
| 1718 | + : input.headers !== undefined | |
| 1719 | + ? input.headers | |
| 1720 | + : (existingBinding?.headers ?? null); | |
| 1721 | + const chosenArgs = | |
| 1722 | + input.args !== undefined ? normalizeStringArray(input.args) : (existingBinding?.args ?? null); | |
| 1723 | + const chosenEnv = | |
| 1724 | + input.env !== undefined ? input.env : (existingBinding?.env ?? null); | |
| 1725 | + const chosenCwd = | |
| 1726 | + input.cwd !== undefined ? trimOrNull(input.cwd) : (existingBinding?.cwd ?? null); | |
| 1605 | 1727 | const now = Date.now(); |
| 1606 | 1728 | |
| 1607 | 1729 | const draftSource = existing |
| @@ -1617,6 +1739,10 @@ const connectMcpSourceInternal = (input: { · 9 unmodified lines | ||
| 1617 | 1739 | transport: chosenTransport, |
| 1618 | 1740 | queryParams: chosenQueryParams, |
| 1619 | 1741 | headers: chosenHeaders, |
| 1742 | + command: chosenCommand, | |
| 1743 | + args: chosenArgs, | |
| 1744 | + env: chosenEnv, | |
| 1745 | + cwd: chosenCwd, | |
| 1620 | 1746 | }, |
| 1621 | 1747 | importAuthPolicy: "reuse_runtime", |
| 1622 | 1748 | importAuth: { kind: "none" }, |
| @@ -1639,6 +1765,10 @@ const connectMcpSourceInternal = (input: { · 16 unmodified lines | ||
| 1639 | 1765 | transport: chosenTransport, |
| 1640 | 1766 | queryParams: chosenQueryParams, |
| 1641 | 1767 | headers: chosenHeaders, |
| 1768 | + command: chosenCommand, | |
| 1769 | + args: chosenArgs, | |
| 1770 | + env: chosenEnv, | |
| 1771 | + cwd: chosenCwd, | |
| 1642 | 1772 | }, |
| 1643 | 1773 | importAuthPolicy: "reuse_runtime", |
| 1644 | 1774 | importAuth: { kind: "none" }, |
| @@ -2681,6 +2811,13 @@ const createRuntimeSourceConnectionService = ( · 1036 unmodified lines | ||
| 2681 | 2811 | endpoint: sourceInput.endpoint, |
| 2682 | 2812 | name: sourceInput.name, |
| 2683 | 2813 | namespace: sourceInput.namespace, |
| 2814 | + transport: sourceInput.transport, | |
| 2815 | + queryParams: sourceInput.queryParams, | |
| 2816 | + headers: sourceInput.headers, | |
| 2817 | + command: sourceInput.command, | |
| 2818 | + args: sourceInput.args, | |
| 2819 | + env: sourceInput.env, | |
| 2820 | + cwd: sourceInput.cwd, | |
| 2684 | 2821 | mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation, |
| 2685 | 2822 | baseUrl: options?.baseUrl, |
| 2686 | 2823 | resolveSecretMaterial: input.resolveSecretMaterial, |
| @@ -2720,6 +2857,10 @@ const createRuntimeSourceConnectionService = ( · 33 unmodified lines | ||
| 2720 | 2857 | transport: sourceInput.transport, |
| 2721 | 2858 | queryParams: sourceInput.queryParams, |
| 2722 | 2859 | headers: sourceInput.headers, |
| 2860 | + command: sourceInput.command, | |
| 2861 | + args: sourceInput.args, | |
| 2862 | + env: sourceInput.env, | |
| 2863 | + cwd: sourceInput.cwd, | |
| 2723 | 2864 | baseUrl: sourceInput.baseUrl, |
| 2724 | 2865 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 2725 | 2866 | }).pipe( |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | ||
| 11 | 11 | "dependencies": { |
| 12 | 12 | "@effect/opentelemetry": "^0.61.0", |
| 13 | 13 | "@effect/platform": "catalog:", |
| 14 | + "@effect/platform-node": "catalog:", | |
| 14 | 15 | "@executor/control-plane": "workspace:*", |
| 15 | 16 | "@executor/executor-mcp": "workspace:*", |
| 16 | 17 | "@opentelemetry/api": "^1.9.0", |
| @@ -1,4 +1,3 @@ | ||
| 1 | −import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; | |
| 2 | 1 | import { |
| 3 | 2 | createServer as createNodeServer, |
| 4 | 3 | type IncomingMessage, |
| @@ -7,7 +6,8 @@ import { · 2 unmodified lines | ||
| 7 | 6 | import type { AddressInfo } from "node:net"; |
| 8 | 7 | import { dirname, extname, resolve } from "node:path"; |
| 9 | 8 | import { Readable } from "node:stream"; |
| 10 | −import { HttpApiBuilder, HttpServer } from "@effect/platform"; | |
| 9 | +import { FileSystem, HttpApiBuilder, HttpServer } from "@effect/platform"; | |
| 10 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 11 | 11 | import { |
| 12 | 12 | createControlPlaneApiLayer, |
| 13 | 13 | createControlPlaneRuntime, |
| @@ -147,9 +147,13 @@ const safeFilePath = (assetsDir: string, pathname: string): string | null => { · 133 unmodified lines | ||
| 147 | 147 | return target.startsWith(root) ? target : null; |
| 148 | 148 | }; |
| 149 | 149 | |
| 150 | −const isRegularFile = async (path: string): Promise<boolean> => { | |
| 150 | +const isRegularFile = async ( | |
| 151 | + fileSystem: FileSystem.FileSystem, | |
| 152 | + path: string, | |
| 153 | +): Promise<boolean> => { | |
| 151 | 154 | try { |
| 152 | − return (await stat(path)).isFile(); | |
| 155 | + const info = await Effect.runPromise(fileSystem.stat(path)); | |
| 156 | + return info.type === "File"; | |
| 153 | 157 | } catch { |
| 154 | 158 | return false; |
| 155 | 159 | } |
| @@ -178,11 +182,12 @@ const contentTypeForPath = (path: string): string => · 22 unmodified lines | ||
| 178 | 182 | contentTypeByExtension[extname(path).toLowerCase()] ?? "application/octet-stream"; |
| 179 | 183 | |
| 180 | 184 | const readResponseFile = async ( |
| 185 | + fileSystem: FileSystem.FileSystem, | |
| 181 | 186 | path: string, |
| 182 | 187 | contentType?: string, |
| 183 | 188 | ): Promise<Response> => { |
| 184 | − const body = await readFile(path); | |
| 185 | − return new Response(body, { | |
| 189 | + const body = await Effect.runPromise(fileSystem.readFile(path)); | |
| 190 | + return new Response(Buffer.from(body), { | |
| 186 | 191 | headers: { |
| 187 | 192 | "content-type": contentType ?? contentTypeForPath(path), |
| 188 | 193 | }, |
| @@ -254,7 +259,11 @@ const writeNodeResponse = async ( · 65 unmodified lines | ||
| 254 | 259 | const wantsHtml = (request: Request): boolean => |
| 255 | 260 | request.headers.get("accept")?.includes("text/html") ?? false; |
| 256 | 261 | |
| 257 | −const serveUiAsset = async (request: Request, ui: StaticUiOptions): Promise<Response | null> => { | |
| 262 | +const serveUiAsset = async ( | |
| 263 | + fileSystem: FileSystem.FileSystem, | |
| 264 | + request: Request, | |
| 265 | + ui: StaticUiOptions, | |
| 266 | +): Promise<Response | null> => { | |
| 258 | 267 | const url = new URL(request.url); |
| 259 | 268 | |
| 260 | 269 | if (ui.devServerUrl) { |
| @@ -267,8 +276,8 @@ const serveUiAsset = async (request: Request, ui: StaticUiOptions): Promise<Resp · 6 unmodified lines | ||
| 267 | 276 | } |
| 268 | 277 | |
| 269 | 278 | const candidatePath = safeFilePath(ui.assetsDir, url.pathname); |
| 270 | − if (candidatePath && await isRegularFile(candidatePath)) { | |
| 271 | − return readResponseFile(candidatePath); | |
| 279 | + if (candidatePath && await isRegularFile(fileSystem, candidatePath)) { | |
| 280 | + return readResponseFile(fileSystem, candidatePath); | |
| 272 | 281 | } |
| 273 | 282 | |
| 274 | 283 | const shouldServeIndex = |
| @@ -281,11 +290,11 @@ const serveUiAsset = async (request: Request, ui: StaticUiOptions): Promise<Resp · 6 unmodified lines | ||
| 281 | 290 | } |
| 282 | 291 | |
| 283 | 292 | const indexPath = resolve(ui.assetsDir, "index.html"); |
| 284 | − if (!(await isRegularFile(indexPath))) { | |
| 293 | + if (!(await isRegularFile(fileSystem, indexPath))) { | |
| 285 | 294 | return null; |
| 286 | 295 | } |
| 287 | 296 | |
| 288 | − return readResponseFile(indexPath, "text/html; charset=utf-8"); | |
| 297 | + return readResponseFile(fileSystem, indexPath, "text/html; charset=utf-8"); | |
| 289 | 298 | }; |
| 290 | 299 | |
| 291 | 300 | const isApiRequest = (request: Request): boolean => { |
| @@ -297,13 +306,17 @@ export const createLocalExecutorRequestHandler = ( · 5 unmodified lines | ||
| 297 | 306 | options: StartLocalExecutorServerOptions = {}, |
| 298 | 307 | ): Effect.Effect<LocalExecutorRequestHandler, Error, Scope.Scope> => |
| 299 | 308 | Effect.gen(function* () { |
| 309 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 300 | 310 | const requestedLocalDataDir = options.localDataDir ?? DEFAULT_LOCAL_DATA_DIR; |
| 301 | 311 | |
| 302 | 312 | if (requestedLocalDataDir !== ":memory:") { |
| 303 | − yield* Effect.tryPromise({ | |
| 304 | − try: () => mkdir(dirname(requestedLocalDataDir), { recursive: true }), | |
| 305 | − catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | |
| 306 | − }); | |
| 313 | + yield* fileSystem.makeDirectory(dirname(requestedLocalDataDir), { | |
| 314 | + recursive: true, | |
| 315 | + }).pipe( | |
| 316 | + Effect.mapError((cause) => | |
| 317 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 318 | + ), | |
| 319 | + ); | |
| 307 | 320 | } |
| 308 | 321 | |
| 309 | 322 | let baseUrlRef: string | undefined; |
| @@ -351,12 +364,13 @@ export const createLocalExecutorRequestHandler = ( · 41 unmodified lines | ||
| 351 | 364 | baseUrlRef = baseUrl; |
| 352 | 365 | }, |
| 353 | 366 | } satisfies LocalExecutorRequestHandler; |
| 354 | − }); | |
| 367 | + }).pipe(Effect.provide(NodeFileSystem.layer)); | |
| 355 | 368 | |
| 356 | 369 | export const createLocalExecutorServer = ( |
| 357 | 370 | options: StartLocalExecutorServerOptions = {}, |
| 358 | 371 | ): Effect.Effect<LocalExecutorServer, Error, Scope.Scope> => |
| 359 | 372 | Effect.gen(function* () { |
| 373 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 360 | 374 | const host = options.host ?? DEFAULT_SERVER_HOST; |
| 361 | 375 | const port = options.port ?? DEFAULT_SERVER_PORT; |
| 362 | 376 | const requestHandler = yield* createLocalExecutorRequestHandler(options); |
| @@ -370,7 +384,7 @@ export const createLocalExecutorServer = ( · 7 unmodified lines | ||
| 370 | 384 | const request = toWebRequest(nodeRequest); |
| 371 | 385 | const response = isApiRequest(request) |
| 372 | 386 | ? await requestHandler.handleApiRequest(request) |
| 373 | − : await serveUiAsset(request, options.ui ?? {}) ?? new Response("Not Found", { status: 404 }); | |
| 387 | + : await serveUiAsset(fileSystem, request, options.ui ?? {}) ?? new Response("Not Found", { status: 404 }); | |
| 374 | 388 | await writeNodeResponse(nodeResponse, response); |
| 375 | 389 | })().catch((cause) => { |
| 376 | 390 | nodeResponse.statusCode = 500; |
| @@ -417,36 +431,48 @@ export const createLocalExecutorServer = ( · 40 unmodified lines | ||
| 417 | 431 | port: resolvedAddress.port, |
| 418 | 432 | baseUrl, |
| 419 | 433 | } satisfies LocalExecutorServer; |
| 420 | − }); | |
| 434 | + }).pipe(Effect.provide(NodeFileSystem.layer)); | |
| 421 | 435 | |
| 422 | 436 | export const runLocalExecutorServer = ( |
| 423 | 437 | options: StartLocalExecutorServerOptions = {}, |
| 424 | 438 | ): Effect.Effect<void, Error, never> => |
| 425 | 439 | Effect.scoped( |
| 426 | 440 | Effect.gen(function* () { |
| 441 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 427 | 442 | const server = yield* createLocalExecutorServer(options); |
| 428 | 443 | const pidFile = options.pidFile ?? DEFAULT_SERVER_PID_FILE; |
| 429 | 444 | |
| 430 | 445 | yield* Effect.acquireRelease( |
| 431 | − Effect.tryPromise({ | |
| 432 | − try: async () => { | |
| 433 | − await mkdir(dirname(pidFile), { recursive: true }); | |
| 434 | − await writeFile(pidFile, JSON.stringify({ | |
| 435 | − pid: process.pid, | |
| 436 | − port: server.port, | |
| 437 | − host: server.host, | |
| 438 | − baseUrl: server.baseUrl, | |
| 439 | − startedAt: Date.now(), | |
| 440 | − logFile: DEFAULT_SERVER_LOG_FILE, | |
| 441 | − }, null, 2)); | |
| 442 | − }, | |
| 443 | − catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), | |
| 446 | + Effect.gen(function* () { | |
| 447 | + yield* fileSystem.makeDirectory(dirname(pidFile), { | |
| 448 | + recursive: true, | |
| 449 | + }).pipe( | |
| 450 | + Effect.mapError((cause) => | |
| 451 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 452 | + ), | |
| 453 | + ); | |
| 454 | + yield* fileSystem.writeFileString(pidFile, JSON.stringify({ | |
| 455 | + pid: process.pid, | |
| 456 | + port: server.port, | |
| 457 | + host: server.host, | |
| 458 | + baseUrl: server.baseUrl, | |
| 459 | + startedAt: Date.now(), | |
| 460 | + logFile: DEFAULT_SERVER_LOG_FILE, | |
| 461 | + }, null, 2)).pipe( | |
| 462 | + Effect.mapError((cause) => | |
| 463 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 464 | + ), | |
| 465 | + ); | |
| 444 | 466 | }), |
| 445 | − () => Effect.tryPromise({ | |
| 446 | − try: () => rm(pidFile, { force: true }), | |
| 447 | − catch: (cause) => | |
| 448 | − cause instanceof Error ? cause : new Error(String(cause ?? "pid file cleanup failed")), | |
| 449 | − }).pipe(Effect.orDie), | |
| 467 | + () => | |
| 468 | + fileSystem.remove(pidFile, { force: true }).pipe( | |
| 469 | + Effect.mapError((cause) => | |
| 470 | + cause instanceof Error | |
| 471 | + ? cause | |
| 472 | + : new Error(String(cause ?? "pid file cleanup failed")), | |
| 473 | + ), | |
| 474 | + Effect.orDie, | |
| 475 | + ), | |
| 450 | 476 | ); |
| 451 | 477 | |
| 452 | 478 | console.error(`executor server listening on ${server.baseUrl}`); |
| @@ -462,4 +488,4 @@ export const runLocalExecutorServer = ( · 9 unmodified lines | ||
| 462 | 488 | }); |
| 463 | 489 | }); |
| 464 | 490 | }), |
| 465 | − ); | |
| 491 | + ).pipe(Effect.provide(NodeFileSystem.layer)); | |
| @@ -1,5 +1,7 @@ | ||
| 1 | −import { readFileSync } from "node:fs"; | |
| 1 | +import { fileURLToPath } from "node:url"; | |
| 2 | 2 | |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 4 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 3 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | 6 | import * as Effect from "effect/Effect"; |
| 5 | 7 | |
| @@ -13,10 +15,17 @@ import type { · 7 unmodified lines | ||
| 13 | 15 | OpenApiToolManifest, |
| 14 | 16 | } from "./types"; |
| 15 | 17 | |
| 16 | −const readFixture = (name: string): string => | |
| 17 | − readFileSync( | |
| 18 | − new URL(`../../../platform/control-plane/src/runtime/fixtures/${name}`, import.meta.url), | |
| 19 | − "utf8", | |
| 18 | +const readFixture = (name: string) => | |
| 19 | + FileSystem.FileSystem.pipe( | |
| 20 | + Effect.flatMap((fs) => | |
| 21 | + fs.readFileString( | |
| 22 | + fileURLToPath( | |
| 23 | + new URL(`../../../platform/control-plane/src/runtime/fixtures/${name}`, import.meta.url), | |
| 24 | + ), | |
| 25 | + "utf8", | |
| 26 | + ) | |
| 27 | + ), | |
| 28 | + Effect.provide(NodeFileSystem.layer), | |
| 20 | 29 | ); |
| 21 | 30 | |
| 22 | 31 | const asObject = (value: unknown): Record<string, unknown> => |
| @@ -322,7 +331,7 @@ describe("openapi real spec coverage", () => { · 299 unmodified lines | ||
| 322 | 331 | `projects every ${fixture.name} operation with request/response schemas that match the raw spec`, |
| 323 | 332 | () => |
| 324 | 333 | Effect.gen(function* () { |
| 325 | − const specText = readFixture(fixture.filename); | |
| 334 | + const specText = yield* readFixture(fixture.filename); | |
| 326 | 335 | const document = parseOpenApiDocument(specText); |
| 327 | 336 | const manifest = yield* extractOpenApiManifest( |
| 328 | 337 | fixture.name.toLowerCase(), |
| @@ -1,5 +1,7 @@ | ||
| 1 | −import { readFileSync } from "node:fs"; | |
| 1 | +import { fileURLToPath } from "node:url"; | |
| 2 | 2 | |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 4 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 3 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | 6 | import * as Effect from "effect/Effect"; |
| 5 | 7 | |
| @@ -8,10 +10,17 @@ import { compileOpenApiToolDefinitions } from "./definitions"; · 2 unmodified lines | ||
| 8 | 10 | import { extractOpenApiManifest } from "./extraction"; |
| 9 | 11 | import { buildOpenApiToolPresentation } from "./tool-presentation"; |
| 10 | 12 | |
| 11 | −const readFixture = (name: string): string => | |
| 12 | − readFileSync( | |
| 13 | − new URL(`../../../platform/control-plane/src/runtime/fixtures/${name}`, import.meta.url), | |
| 14 | − "utf8", | |
| 13 | +const readFixture = (name: string) => | |
| 14 | + FileSystem.FileSystem.pipe( | |
| 15 | + Effect.flatMap((fs) => | |
| 16 | + fs.readFileString( | |
| 17 | + fileURLToPath( | |
| 18 | + new URL(`../../../platform/control-plane/src/runtime/fixtures/${name}`, import.meta.url), | |
| 19 | + ), | |
| 20 | + "utf8", | |
| 21 | + ) | |
| 22 | + ), | |
| 23 | + Effect.provide(NodeFileSystem.layer), | |
| 15 | 24 | ); |
| 16 | 25 | |
| 17 | 26 | describe("buildOpenApiToolPresentation", () => { |
| @@ -201,7 +210,7 @@ describe("buildOpenApiToolPresentation", () => { · 183 unmodified lines | ||
| 201 | 210 | "resolves request and response schemas from ref hints for the real Neon OpenAPI spec", |
| 202 | 211 | () => |
| 203 | 212 | Effect.gen(function* () { |
| 204 | − const specText = readFixture("neon-openapi.json"); | |
| 213 | + const specText = yield* readFixture("neon-openapi.json"); | |
| 205 | 214 | const manifest = yield* extractOpenApiManifest("neon", specText); |
| 206 | 215 | const definition = compileOpenApiToolDefinitions(manifest).find( |
| 207 | 216 | (candidate) => candidate.toolId === "apiKey.createApiKey", |
| @@ -254,7 +263,7 @@ describe("buildOpenApiToolPresentation", () => { · 46 unmodified lines | ||
| 254 | 263 | "preserves response wrappers for response-only Neon operations", |
| 255 | 264 | () => |
| 256 | 265 | Effect.gen(function* () { |
| 257 | − const specText = readFixture("neon-openapi.json"); | |
| 266 | + const specText = yield* readFixture("neon-openapi.json"); | |
| 258 | 267 | const manifest = yield* extractOpenApiManifest("neon", specText); |
| 259 | 268 | const definition = compileOpenApiToolDefinitions(manifest).find( |
| 260 | 269 | (candidate) => candidate.toolId === "apiKey.listApiKeys", |
| @@ -292,7 +301,7 @@ describe("buildOpenApiToolPresentation", () => { · 31 unmodified lines | ||
| 292 | 301 | "extracts parameter schemas and descriptions for parameter-only Vercel operations", |
| 293 | 302 | () => |
| 294 | 303 | Effect.gen(function* () { |
| 295 | − const specText = readFixture("vercel-openapi.json"); | |
| 304 | + const specText = yield* readFixture("vercel-openapi.json"); | |
| 296 | 305 | const manifest = yield* extractOpenApiManifest("vercel", specText); |
| 297 | 306 | const definition = compileOpenApiToolDefinitions(manifest).find( |
| 298 | 307 | (candidate) => candidate.toolId === "accessGroups.listAccessGroupProjects", |
| @@ -11,6 +11,7 @@ Why it lives here: · 10 unmodified lines | ||
| 11 | 11 | Current rules: |
| 12 | 12 | |
| 13 | 13 | - `executor-monorepo/no-cross-workspace-relative-imports` |
| 14 | +- `executor-monorepo/no-node-fs-with-effect-imports` | |
| 14 | 15 | - `executor-monorepo/no-raw-effect-fail-errors` |
| 15 | 16 | - `executor-monorepo/no-yield-effect-fail` |
| 16 | 17 | - `executor-monorepo/no-workspace-src-imports` |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { definePlugin } from "@oxlint/plugins"; |
| 2 | 2 | |
| 3 | 3 | import noCrossWorkspaceRelativeImports from "./rules/no-cross-workspace-relative-imports.mjs"; |
| 4 | +import noNodeFsWithEffectImports from "./rules/no-node-fs-with-effect-imports.mjs"; | |
| 4 | 5 | import noRawEffectFailErrors from "./rules/no-raw-effect-fail-errors.mjs"; |
| 5 | 6 | import noYieldEffectFail from "./rules/no-yield-effect-fail.mjs"; |
| 6 | 7 | import noWorkspaceSrcImports from "./rules/no-workspace-src-imports.mjs"; |
| @@ -11,6 +12,7 @@ export default definePlugin({ · 4 unmodified lines | ||
| 11 | 12 | }, |
| 12 | 13 | rules: { |
| 13 | 14 | "no-cross-workspace-relative-imports": noCrossWorkspaceRelativeImports, |
| 15 | + "no-node-fs-with-effect-imports": noNodeFsWithEffectImports, | |
| 14 | 16 | "no-raw-effect-fail-errors": noRawEffectFailErrors, |
| 15 | 17 | "no-yield-effect-fail": noYieldEffectFail, |
| 16 | 18 | "no-workspace-src-imports": noWorkspaceSrcImports, |
| @@ -0,0 +1,70 @@ | ||
| 1 | +import { defineRule } from "@oxlint/plugins"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + createModuleSourceVisitor, | |
| 5 | + readStaticSpecifier, | |
| 6 | +} from "../workspace-utils.mjs"; | |
| 7 | + | |
| 8 | +const isEffectSource = (specifier) => | |
| 9 | + specifier === "effect" | |
| 10 | + || specifier.startsWith("effect/") | |
| 11 | + || specifier.startsWith("@effect/"); | |
| 12 | + | |
| 13 | +const isNodeFsSource = (specifier) => | |
| 14 | + specifier === "node:fs" || specifier === "node:fs/promises"; | |
| 15 | + | |
| 16 | +export default defineRule({ | |
| 17 | + meta: { | |
| 18 | + type: "suggestion", | |
| 19 | + docs: { | |
| 20 | + description: | |
| 21 | + "Disallow node:fs imports in modules that already depend on Effect.", | |
| 22 | + recommended: true, | |
| 23 | + }, | |
| 24 | + messages: { | |
| 25 | + noNodeFsWithEffectImports: | |
| 26 | + "This module already depends on Effect. Do not import {{moduleName}} here. Depend on `FileSystem.FileSystem` from `@effect/platform` instead, and provide `NodeFileSystem.layer` only at the Node boundary or in tests.", | |
| 27 | + }, | |
| 28 | + }, | |
| 29 | + create(context) { | |
| 30 | + let hasEffectImport = false; | |
| 31 | + const nodeFsImports = []; | |
| 32 | + | |
| 33 | + const visitSource = (sourceNode) => { | |
| 34 | + const specifier = readStaticSpecifier(sourceNode); | |
| 35 | + if (!specifier) { | |
| 36 | + return; | |
| 37 | + } | |
| 38 | + | |
| 39 | + if (isEffectSource(specifier)) { | |
| 40 | + hasEffectImport = true; | |
| 41 | + } | |
| 42 | + | |
| 43 | + if (isNodeFsSource(specifier)) { | |
| 44 | + nodeFsImports.push({ | |
| 45 | + moduleName: specifier, | |
| 46 | + node: sourceNode, | |
| 47 | + }); | |
| 48 | + } | |
| 49 | + }; | |
| 50 | + | |
| 51 | + return { | |
| 52 | + ...createModuleSourceVisitor(visitSource), | |
| 53 | + "Program:exit"() { | |
| 54 | + if (!hasEffectImport) { | |
| 55 | + return; | |
| 56 | + } | |
| 57 | + | |
| 58 | + for (const entry of nodeFsImports) { | |
| 59 | + context.report({ | |
| 60 | + node: entry.node, | |
| 61 | + messageId: "noNodeFsWithEffectImports", | |
| 62 | + data: { | |
| 63 | + moduleName: entry.moduleName, | |
| 64 | + }, | |
| 65 | + }); | |
| 66 | + } | |
| 67 | + }, | |
| 68 | + }; | |
| 69 | + }, | |
| 70 | +}); | |