History

Ban node:fs in Effect modules

Rhys Sullivan committed 8 months ago

Commit 13fd4041 parent 820797928 files · +968 −496Browse files

Unified · Split

28 files
  1. .oxlintrc.jsonc +1 −0
  2. apps/executor/src/cli/main.ts +99 −79
  3. apps/executor/src/distribution/harness.ts +35 −28
  4. bun.lock +2 −1
  5. packages/clients/react/src/index.test.tsx +9 −6
  6. packages/kernel/runtime-deno-subprocess/src/index.ts +5 −2
  7. packages/platform/control-plane/src/runtime/catalog/source/type-declarations.test.ts +60 −26
  8. packages/platform/control-plane/src/runtime/control-plane-runtime.test.ts +2 −3
  9. packages/platform/control-plane/src/runtime/execution/http.test.ts +9 −3
  10. packages/platform/control-plane/src/runtime/execution/mcp-resume.test.ts +8 −3
  11. packages/platform/control-plane/src/runtime/execution/runtime-config.test.ts +25 −17
  12. packages/platform/control-plane/src/runtime/index.ts +11 −2
  13. packages/platform/control-plane/src/runtime/local/config.test.ts +21 −19
  14. packages/platform/control-plane/src/runtime/local/control-plane-store.test.ts +31 −20
  15. packages/platform/control-plane/src/runtime/local/control-plane-store.ts +68 −41
  16. packages/platform/control-plane/src/runtime/local/installation.test.ts +32 −19
  17. packages/platform/control-plane/src/runtime/local/secret-material-providers.ts +167 −124
  18. packages/platform/control-plane/src/runtime/local/source-artifacts.test.ts +32 −21
  19. packages/platform/control-plane/src/runtime/local/tools.ts +7 −7
  20. packages/platform/control-plane/src/runtime/sources/source-adapter-fixture-matrix.test.ts +19 −9
  21. packages/platform/control-plane/src/runtime/sources/source-auth-service.ts +156 −15
  22. packages/platform/server/package.json +1 −0
  23. packages/platform/server/src/index.ts +63 −37
  24. packages/sources/openapi/src/real-spec-coverage.test.ts +15 −6
  25. packages/sources/openapi/src/tool-presentation.test.ts +17 −8
  26. tools/oxlint/README.md +1 −0
  27. tools/oxlint/plugin.mjs +2 −0
  28. tools/oxlint/rules/no-node-fs-with-effect-imports.mjs +70 −0
.oxlintrc.jsonc+1−0
@@ -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"
apps/executor/src/cli/main.ts+99−79
@@ -1,9 +1,7 @@
1import { existsSync, readFileSync } from "node:fs";
2import { mkdir, open, readFile, rm } from "node:fs/promises";
3import { spawn } from "node:child_process";1import { spawn } from "node:child_process";
4import { dirname, resolve } from "node:path";2import { createRequire } from "node:module";
3import { dirname } from "node:path";
5import { createInterface } from "node:readline/promises";4import { createInterface } from "node:readline/promises";
6import { fileURLToPath } from "node:url";
7import { FileSystem } from "@effect/platform";5import { FileSystem } from "@effect/platform";
8import { Args, Command, Options } from "@effect/cli";6import { Args, Command, Options } from "@effect/cli";
9import {7import {
@@ -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
153const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema);151const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema);
154const cliSourceDir = dirname(fileURLToPath(import.meta.url));152const require = createRequire(import.meta.url);
155const CLI_NAME = "executor";153const CLI_NAME = "executor";
156const CLI_VERSION = (() => {154const 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
430const startServerInBackground = (port: number) =>423const 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
450type LocalServerPidRecord = {449type 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
459const readPidRecord = (): Effect.Effect<LocalServerPidRecord | null, never, never> =>458const 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
468const isPidRunning = (pid: number): boolean => {475const 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
550const getServerStatus = (baseUrl: string): Effect.Effect<LocalServerStatus, Error, never> =>557const 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
648const stopServer = (baseUrl: string) =>657const 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
1181const getDenoVersion = (): Effect.Effect<string | null, never, never> =>1190const 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
1215const sandboxCommand = Command.make(1235const sandboxCommand = Command.make(
apps/executor/src/distribution/harness.ts+35−28
@@ -1,9 +1,10 @@
1import { spawn } from "node:child_process";1import { spawn } from "node:child_process";
2import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3import { createServer } from "node:net";2import { createServer } from "node:net";
4import { tmpdir } from "node:os";3import { tmpdir } from "node:os";
5import { dirname, join } from "node:path";4import { dirname, join } from "node:path";
6 5
6import { FileSystem } from "@effect/platform";
7import { NodeFileSystem } from "@effect/platform-node";
7import * as Context from "effect/Context";8import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
@@ -11,6 +12,9 @@ import * as Layer from "effect/Layer"; · 1 unmodified line
11import { buildDistributionPackage } from "./artifact";12import { buildDistributionPackage } from "./artifact";
12import { executorAppEffectError } from "../effect-errors";13import { executorAppEffectError } from "../effect-errors";
13 14
15const toError = (cause: unknown): Error =>
16 cause instanceof Error ? cause : new Error(String(cause));
17
14type CommandResult = {18type 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
173export const LocalDistributionHarnessLive = Layer.scoped(177export 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);
bun.lock+2−1
@@ -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",
packages/clients/react/src/index.test.tsx+9−6
@@ -1,7 +1,7 @@
1import { createServer } from "node:http";1import { createServer } from "node:http";
2 2
3import { HttpApiBuilder, HttpServer } from "@effect/platform";3import { FileSystem, HttpApiBuilder, HttpServer } from "@effect/platform";
4import { NodeHttpServer } from "@effect/platform-node";4import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node";
5import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
6import {6import {
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
94const startControlPlaneServer = async (): Promise<ApiServer> => {94const 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});
750import { mkdtempSync } from "node:fs";
751import { tmpdir } from "node:os";
752import { join } from "node:path";
packages/kernel/runtime-deno-subprocess/src/index.ts+5−2
@@ -1,5 +1,4 @@
1import { spawnSync } from "node:child_process";1import { spawnSync } from "node:child_process";
2import { existsSync } from "node:fs";
3import { fileURLToPath } from "node:url";2import { fileURLToPath } from "node:url";
4 3
5import type {4import 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 }
packages/platform/control-plane/src/runtime/catalog/source/type-declarations.test.ts+60−26
@@ -1,6 +1,7 @@
1import { mkdtempSync, readFileSync } from "node:fs";
2import { tmpdir } from "node:os";1import { tmpdir } from "node:os";
3import { join } from "node:path";2import { join } from "node:path";
3import { FileSystem } from "@effect/platform";
4import { NodeFileSystem } from "@effect/platform-node";
4import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";6import * 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
329const 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
340const readTextFile = (path: string) =>
341 FileSystem.FileSystem.pipe(
342 Effect.flatMap((fs) => fs.readFileString(path, "utf8")),
343 Effect.provide(NodeFileSystem.layer),
344 );
345
346const fileExists = (path: string) =>
347 FileSystem.FileSystem.pipe(
348 Effect.flatMap((fs) => fs.exists(path)),
349 Effect.provide(NodeFileSystem.layer),
350 );
351
328describe("source-type-declarations", () => {352describe("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<{");
packages/platform/control-plane/src/runtime/control-plane-runtime.test.ts+2−3
@@ -1,5 +1,4 @@
1import { createServer } from "node:http";1import { createServer } from "node:http";
2import { readFileSync } from "node:fs";
3import { join } from "node:path";2import { join } from "node:path";
4 3
5import { FileSystem } from "@effect/platform";4import { 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();
packages/platform/control-plane/src/runtime/execution/http.test.ts+9−3
@@ -1,6 +1,7 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";1import { tmpdir } from "node:os";
3import { join } from "node:path";2import { join } from "node:path";
3import { FileSystem } from "@effect/platform";
4import { NodeFileSystem } from "@effect/platform-node";
4import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
@@ -40,8 +41,12 @@ const makeExecutionResolver = () => { · 33 unmodified lines
40};41};
41 42
42const makeRuntime = Effect.acquireRelease(43const 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:",
packages/platform/control-plane/src/runtime/execution/mcp-resume.test.ts+8−3
@@ -5,9 +5,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 4 unmodified lines
5import { Client } from "@modelcontextprotocol/sdk/client/index.js";5import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";6import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";7import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8import { mkdtempSync } from "node:fs";
9import { tmpdir } from "node:os";8import { tmpdir } from "node:os";
10import { join } from "node:path";9import { join } from "node:path";
10import { FileSystem } from "@effect/platform";
11import { NodeFileSystem } from "@effect/platform-node";
11import { describe, expect, it } from "@effect/vitest";12import { describe, expect, it } from "@effect/vitest";
12import { assertTrue } from "@effect/vitest/utils";13import { assertTrue } from "@effect/vitest/utils";
13import * as Effect from "effect/Effect";14import * 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});
packages/platform/control-plane/src/runtime/execution/runtime-config.test.ts+25−17
@@ -1,6 +1,7 @@
1import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2import { tmpdir } from "node:os";1import { tmpdir } from "node:os";
3import { join } from "node:path";2import { join } from "node:path";
3import { FileSystem } from "@effect/platform";
4import { NodeFileSystem } from "@effect/platform-node";
4import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6 7
@@ -12,26 +13,33 @@ import { withControlPlaneClient } from "./test-http-client"; · 5 unmodified lines
12const writeProjectConfig = (13const 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
25const makeRuntime = (config: Record<string, unknown>) => {27const 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};
packages/platform/control-plane/src/runtime/index.ts+11−2
@@ -1,9 +1,11 @@
1import { FileSystem } from "@effect/platform";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
2import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
4import * as ManagedRuntime from "effect/ManagedRuntime";5import * as ManagedRuntime from "effect/ManagedRuntime";
5import * as Scope from "effect/Scope";6import * as Scope from "effect/Scope";
6import { NodeFileSystem } from "@effect/platform-node";7import { NodeFileSystem } from "@effect/platform-node";
8import { clearAllMcpConnectionPools } from "@executor/source-mcp";
7 9
8import { type ControlPlaneApiRuntimeContext } from "#api";10import { type ControlPlaneApiRuntimeContext } from "#api";
9import type { LocalInstallation } from "#schema";11import 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));
packages/platform/control-plane/src/runtime/local/config.test.ts+21−19
@@ -1,7 +1,6 @@
1import { mkdtempSync } from "node:fs";
2import { mkdir, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";1import { tmpdir } from "node:os";
4import { join } from "node:path";2import { join } from "node:path";
3import { FileSystem } from "@effect/platform";
5import { NodeFileSystem } from "@effect/platform-node";4import { NodeFileSystem } from "@effect/platform-node";
6import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
7import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
@@ -15,18 +14,25 @@ import { · 7 unmodified lines
15} from "./config";14} from "./config";
16 15
17const makeWorkspaceRoot = () =>16const 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
20describe("local-config", () => {26describe("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 config37 // 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 });
packages/platform/control-plane/src/runtime/local/control-plane-store.test.ts+31−20
@@ -1,6 +1,7 @@
1import { existsSync, mkdtempSync, statSync } from "node:fs";
2import { tmpdir } from "node:os";1import { tmpdir } from "node:os";
3import { join } from "node:path";2import { join } from "node:path";
3import { FileSystem } from "@effect/platform";
4import { NodeFileSystem } from "@effect/platform-node";
4import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";6import * 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
14const makeContext = (): ResolvedLocalWorkspaceContext => {15const 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
30describe("local-control-plane-store", () => {40describe("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});
packages/platform/control-plane/src/runtime/local/control-plane-store.ts+68−41
@@ -1,7 +1,7 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { existsSync } from "node:fs";
3import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4import { dirname, join } from "node:path";2import { dirname, join } from "node:path";
3import { FileSystem } from "@effect/platform";
4import { NodeFileSystem } from "@effect/platform-node";
5 5
6import {6import {
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
63const decodeLocalControlPlaneState = Schema.decodeUnknownSync(63const 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
121const readStateFromDisk = async (121const 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
127const 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
132const 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
133const writeStateToDisk = async (157const 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
147export const loadLocalControlPlaneState = (179export 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
158export const writeLocalControlPlaneState = (input: {184export 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
170const mergeById = <T extends { id: string }>(190const 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
341const createStateManager = (context: ResolvedLocalWorkspaceContext) => {361const 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
410export const createLocalControlPlaneStore = (435export 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
1190export const createLocalControlPlanePersistence = (1216export 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
packages/platform/control-plane/src/runtime/local/installation.test.ts+32−19
@@ -1,6 +1,6 @@
1import { existsSync, mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";1import { tmpdir } from "node:os";
3import { join } from "node:path";2import { join } from "node:path";
3import { FileSystem } from "@effect/platform";
4import { NodeFileSystem } from "@effect/platform-node";4import { NodeFileSystem } from "@effect/platform-node";
5import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
@@ -9,40 +9,53 @@ import { createControlPlaneRuntime } from "../index"; · 2 unmodified lines
9import { getOrProvisionLocalInstallation } from "./installation";9import { getOrProvisionLocalInstallation } from "./installation";
10import { resolveLocalWorkspaceContext } from "./config";10import { resolveLocalWorkspaceContext } from "./config";
11 11
12const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-"));
13const TEST_HOME_CONFIG_PATH = join(TEST_WORKSPACE_ROOT, ".executor-home.jsonc");
14const TEST_HOME_STATE_DIRECTORY = join(TEST_WORKSPACE_ROOT, ".executor-home-state");
15
16const makeRuntime = Effect.acquireRelease(12const 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
26describe("local-installation", () => {38describe("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;
packages/platform/control-plane/src/runtime/local/secret-material-providers.ts+167−124
@@ -1,8 +1,8 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { spawn } from "node:child_process";2import { spawn } from "node:child_process";
3import { promises as fs } from "node:fs";
4import { lstatSync, realpathSync } from "node:fs";
5import { isAbsolute } from "node:path";3import { isAbsolute } from "node:path";
4import { FileSystem } from "@effect/platform";
5import { NodeFileSystem } from "@effect/platform-node";
6 6
7import {7import {
8 type LocalConfigSecretProvider,8 type LocalConfigSecretProvider,
@@ -29,6 +29,9 @@ export const PARAMS_SECRET_PROVIDER_ID = "params"; · 20 unmodified lines
29export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain";29export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain";
30export const LOCAL_SECRET_PROVIDER_ID = "local";30export const LOCAL_SECRET_PROVIDER_ID = "local";
31 31
32const toError = (cause: unknown): Error =>
33 cause instanceof Error ? cause : new Error(String(cause));
34
32export type SecretStoreProviderId =35export type SecretStoreProviderId =
33 | typeof KEYCHAIN_SECRET_PROVIDER_ID36 | 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
859const isRegularFilePath = (path: string, allowSymlink: boolean): boolean => {862const 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
871const 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 }882const 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
883const readFileSecretValue = (input: {904const 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.handle925 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
932const resolveConfiguredSecretProvider = (input: {957const 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 === null986 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
1005export const createDefaultSecretMaterialResolver = (input: {1048export 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
1043export const createDefaultSecretMaterialStorer = (input: {1086export const createDefaultSecretMaterialStorer = (input: {
packages/platform/control-plane/src/runtime/local/source-artifacts.test.ts+32−21
@@ -1,6 +1,6 @@
1import { existsSync, mkdtempSync, readFileSync } from "node:fs";
2import { tmpdir } from "node:os";1import { tmpdir } from "node:os";
3import { join } from "node:path";2import { join } from "node:path";
3import { FileSystem } from "@effect/platform";
4import { NodeFileSystem } from "@effect/platform-node";4import { NodeFileSystem } from "@effect/platform-node";
5import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
@@ -17,20 +17,30 @@ import { createCatalogImportMetadata } from "@executor/source-core"; · 10 unmodified lines
17import { createGraphqlCatalogFragment } from "@executor/source-graphql";17import { createGraphqlCatalogFragment } from "@executor/source-graphql";
18import { createOpenApiCatalogFragment } from "@executor/source-openapi";18import { createOpenApiCatalogFragment } from "@executor/source-openapi";
19 19
20const makeContext = (): ResolvedLocalWorkspaceContext => {20const 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
35const makeSource = (): Source => ({45const makeSource = (): Source => ({
36 id: SourceIdSchema.make("src_test"),46 id: SourceIdSchema.make("src_test"),
@@ -148,7 +158,8 @@ const makeGraphqlArtifact = () => { · 111 unmodified lines
148describe("local-source-artifacts", () => {158describe("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({
packages/platform/control-plane/src/runtime/local/tools.ts+7−7
@@ -1,7 +1,7 @@
1import { createHash } from "node:crypto";1import { createHash } from "node:crypto";
2import { existsSync } from "node:fs";2import { createRequire } from "node:module";
3import { dirname, extname, join, relative, resolve, sep } from "node:path";3import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
4import { fileURLToPath, pathToFileURL } from "node:url";4import { pathToFileURL } from "node:url";
5import { FileSystem } from "@effect/platform";5import { FileSystem } from "@effect/platform";
6import {6import {
7 createToolCatalogFromTools,7 createToolCatalogFromTools,
@@ -332,12 +332,12 @@ const transpileSourceFile = (input: { · 324 unmodified lines
332 });332 });
333 333
334const resolveExecutorNodeModulesDirectory = (): string | null => {334const 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);
packages/platform/control-plane/src/runtime/sources/source-adapter-fixture-matrix.test.ts+19−9
@@ -1,13 +1,15 @@
1import { readFileSync } from "node:fs";
2import { createServer } from "node:http";1import { createServer } from "node:http";
2import { fileURLToPath } from "node:url";
3 3
4import {4import {
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";
12import { NodeFileSystem } from "@effect/platform-node";
11import {13import {
12 buildOpenApiToolPresentation,14 buildOpenApiToolPresentation,
13 compileOpenApiToolDefinitions,15 compileOpenApiToolDefinitions,
@@ -57,8 +59,16 @@ import { createOpenApiCatalogSnapshot } from "@executor/source-openapi"; · 43 unmodified lines
57 59
58const FIXTURE_WORKSPACE_ID = WorkspaceIdSchema.make("ws_source_fixture_matrix");60const FIXTURE_WORKSPACE_ID = WorkspaceIdSchema.make("ws_source_fixture_matrix");
59 61
60const readFixture = (name: string): string =>62const 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
63const makeSource = (input: {73const 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",
packages/platform/control-plane/src/runtime/sources/source-auth-service.ts+156−15
@@ -1,6 +1,7 @@
1import {1import {
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";
6import {7import {
@@ -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
129const 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
138const 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
152const 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
162const 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
176const 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
128const resolveSourceCredentialOauthCompleteUrl = (input: {196const 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
155const normalizeEndpoint = (endpoint: string): string => {223function 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
160const defaultGoogleDiscoveryUrl = (service: string, version: string): string =>228const 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.sourceId1651 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?.namespace1706 ?? 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 = existing1729 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(
packages/platform/server/package.json+1−0
@@ -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",
packages/platform/server/src/index.ts+63−37
@@ -1,4 +1,3 @@
1import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2import {1import {
3 createServer as createNodeServer,2 createServer as createNodeServer,
4 type IncomingMessage,3 type IncomingMessage,
@@ -7,7 +6,8 @@ import { · 2 unmodified lines
7import type { AddressInfo } from "node:net";6import type { AddressInfo } from "node:net";
8import { dirname, extname, resolve } from "node:path";7import { dirname, extname, resolve } from "node:path";
9import { Readable } from "node:stream";8import { Readable } from "node:stream";
10import { HttpApiBuilder, HttpServer } from "@effect/platform";9import { FileSystem, HttpApiBuilder, HttpServer } from "@effect/platform";
10import { NodeFileSystem } from "@effect/platform-node";
11import {11import {
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
150const isRegularFile = async (path: string): Promise<boolean> => {150const 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
180const readResponseFile = async (184const 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
254const wantsHtml = (request: Request): boolean =>259const wantsHtml = (request: Request): boolean =>
255 request.headers.get("accept")?.includes("text/html") ?? false;260 request.headers.get("accept")?.includes("text/html") ?? false;
256 261
257const serveUiAsset = async (request: Request, ui: StaticUiOptions): Promise<Response | null> => {262const 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
291const isApiRequest = (request: Request): boolean => {300const 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
356export const createLocalExecutorServer = (369export 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
422export const runLocalExecutorServer = (436export 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));
packages/sources/openapi/src/real-spec-coverage.test.ts+15−6
@@ -1,5 +1,7 @@
1import { readFileSync } from "node:fs";1import { fileURLToPath } from "node:url";
2 2
3import { FileSystem } from "@effect/platform";
4import { NodeFileSystem } from "@effect/platform-node";
3import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";6import * 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
16const readFixture = (name: string): string =>18const 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
22const asObject = (value: unknown): Record<string, unknown> =>31const 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(),
packages/sources/openapi/src/tool-presentation.test.ts+17−8
@@ -1,5 +1,7 @@
1import { readFileSync } from "node:fs";1import { fileURLToPath } from "node:url";
2 2
3import { FileSystem } from "@effect/platform";
4import { NodeFileSystem } from "@effect/platform-node";
3import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
5 7
@@ -8,10 +10,17 @@ import { compileOpenApiToolDefinitions } from "./definitions"; · 2 unmodified lines
8import { extractOpenApiManifest } from "./extraction";10import { extractOpenApiManifest } from "./extraction";
9import { buildOpenApiToolPresentation } from "./tool-presentation";11import { buildOpenApiToolPresentation } from "./tool-presentation";
10 12
11const readFixture = (name: string): string =>13const 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
17describe("buildOpenApiToolPresentation", () => {26describe("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",
tools/oxlint/README.md+1−0
@@ -11,6 +11,7 @@ Why it lives here: · 10 unmodified lines
11Current rules:11Current 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`
tools/oxlint/plugin.mjs+2−0
@@ -1,6 +1,7 @@
1import { definePlugin } from "@oxlint/plugins";1import { definePlugin } from "@oxlint/plugins";
2 2
3import noCrossWorkspaceRelativeImports from "./rules/no-cross-workspace-relative-imports.mjs";3import noCrossWorkspaceRelativeImports from "./rules/no-cross-workspace-relative-imports.mjs";
4import noNodeFsWithEffectImports from "./rules/no-node-fs-with-effect-imports.mjs";
4import noRawEffectFailErrors from "./rules/no-raw-effect-fail-errors.mjs";5import noRawEffectFailErrors from "./rules/no-raw-effect-fail-errors.mjs";
5import noYieldEffectFail from "./rules/no-yield-effect-fail.mjs";6import noYieldEffectFail from "./rules/no-yield-effect-fail.mjs";
6import noWorkspaceSrcImports from "./rules/no-workspace-src-imports.mjs";7import 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,
tools/oxlint/rules/no-node-fs-with-effect-imports.mjs+70−0
@@ -0,0 +1,70 @@
1import { defineRule } from "@oxlint/plugins";
2
3import {
4 createModuleSourceVisitor,
5 readStaticSpecifier,
6} from "../workspace-utils.mjs";
7
8const isEffectSource = (specifier) =>
9 specifier === "effect"
10 || specifier.startsWith("effect/")
11 || specifier.startsWith("@effect/");
12
13const isNodeFsSource = (specifier) =>
14 specifier === "node:fs" || specifier === "node:fs/promises";
15
16export 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});