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