Unified · Split
| @@ -10,6 +10,9 @@ · 9 unmodified lines | ||
| 10 | 10 | "typescript": "^5.9.3", |
| 11 | 11 | }, |
| 12 | 12 | }, |
| 13 | + "apps/docs": { | |
| 14 | + "name": "@executor/docs", | |
| 15 | + }, | |
| 13 | 16 | "apps/executor": { |
| 14 | 17 | "name": "executor", |
| 15 | 18 | "version": "1.1.9", |
| @@ -79,9 +82,6 @@ · 63 unmodified lines | ||
| 79 | 82 | "vite": "^7.3.1", |
| 80 | 83 | }, |
| 81 | 84 | }, |
| 82 | − "docs": { | |
| 83 | − "name": "@executor/docs", | |
| 84 | − }, | |
| 85 | 85 | "packages/ai-sdk-adapter": { |
| 86 | 86 | "name": "@executor/ai-sdk-adapter", |
| 87 | 87 | "dependencies": { |
| @@ -171,6 +171,7 @@ · 83 unmodified lines | ||
| 171 | 171 | "drizzle-orm": "1.0.0-beta.15-859cf75", |
| 172 | 172 | "effect": "catalog:", |
| 173 | 173 | "graphql": "^16.12.0", |
| 174 | + "jsonc-parser": "3.3.1", | |
| 174 | 175 | "postgres": "^3.4.7", |
| 175 | 176 | "prettier": "^3.8.1", |
| 176 | 177 | }, |
| @@ -496,7 +497,7 @@ · 319 unmodified lines | ||
| 496 | 497 | |
| 497 | 498 | "@executor/control-plane": ["@executor/control-plane@workspace:packages/control-plane"], |
| 498 | 499 | |
| 499 | − "@executor/docs": ["@executor/docs@workspace:docs"], | |
| 500 | + "@executor/docs": ["@executor/docs@workspace:apps/docs"], | |
| 500 | 501 | |
| 501 | 502 | "@executor/executor-mcp": ["@executor/executor-mcp@workspace:packages/executor-mcp"], |
| 502 | 503 | |
| @@ -34,6 +34,7 @@ · 33 unmodified lines | ||
| 34 | 34 | "drizzle-orm": "1.0.0-beta.15-859cf75", |
| 35 | 35 | "effect": "catalog:", |
| 36 | 36 | "graphql": "^16.12.0", |
| 37 | + "jsonc-parser": "3.3.1", | |
| 37 | 38 | "postgres": "^3.4.7", |
| 38 | 39 | "prettier": "^3.8.1" |
| 39 | 40 | }, |
| @@ -0,0 +1,96 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { mkdir, writeFile } from "node:fs/promises"; | |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { join } from "node:path"; | |
| 5 | +import { describe, expect, it } from "@effect/vitest"; | |
| 6 | +import * as Effect from "effect/Effect"; | |
| 7 | + | |
| 8 | +import { | |
| 9 | + loadLocalExecutorConfig, | |
| 10 | + resolveLocalWorkspaceContext, | |
| 11 | +} from "./local-config"; | |
| 12 | + | |
| 13 | +const makeWorkspaceRoot = () => | |
| 14 | + mkdtempSync(join(tmpdir(), "executor-local-config-")); | |
| 15 | + | |
| 16 | +describe("local-config", () => { | |
| 17 | + it.effect("parses jsonc project config with comments and trailing commas", () => | |
| 18 | + Effect.gen(function* () { | |
| 19 | + const workspaceRoot = makeWorkspaceRoot(); | |
| 20 | + const configDirectory = join(workspaceRoot, ".executor"); | |
| 21 | + yield* Effect.promise(() => mkdir(configDirectory, { recursive: true })); | |
| 22 | + yield* Effect.promise(() => | |
| 23 | + writeFile( | |
| 24 | + join(configDirectory, "executor.jsonc"), | |
| 25 | + `{ | |
| 26 | + // local workspace config | |
| 27 | + "sources": { | |
| 28 | + "github": { | |
| 29 | + "kind": "openapi", | |
| 30 | + "connection": { | |
| 31 | + "endpoint": "https://api.github.com", | |
| 32 | + }, | |
| 33 | + "binding": { | |
| 34 | + "specUrl": "https://example.com/openapi.json", | |
| 35 | + }, | |
| 36 | + }, | |
| 37 | + }, | |
| 38 | +} | |
| 39 | +`, | |
| 40 | + "utf8", | |
| 41 | + ), | |
| 42 | + ); | |
| 43 | + | |
| 44 | + const context = yield* Effect.promise(() => | |
| 45 | + resolveLocalWorkspaceContext({ workspaceRoot }), | |
| 46 | + ); | |
| 47 | + const loaded = yield* Effect.promise(() => | |
| 48 | + loadLocalExecutorConfig(context), | |
| 49 | + ); | |
| 50 | + | |
| 51 | + expect(loaded.config?.sources?.github?.kind).toBe("openapi"); | |
| 52 | + expect(loaded.config?.sources?.github?.connection.endpoint).toBe( | |
| 53 | + "https://api.github.com", | |
| 54 | + ); | |
| 55 | + }), | |
| 56 | + ); | |
| 57 | + | |
| 58 | + it.effect("reports jsonc syntax errors with line and column details", () => | |
| 59 | + Effect.gen(function* () { | |
| 60 | + const workspaceRoot = makeWorkspaceRoot(); | |
| 61 | + const configDirectory = join(workspaceRoot, ".executor"); | |
| 62 | + yield* Effect.promise(() => mkdir(configDirectory, { recursive: true })); | |
| 63 | + yield* Effect.promise(() => | |
| 64 | + writeFile( | |
| 65 | + join(configDirectory, "executor.jsonc"), | |
| 66 | + `{ | |
| 67 | + "sources": { | |
| 68 | + "github": { | |
| 69 | + "kind": "openapi" | |
| 70 | + "connection": { | |
| 71 | + "endpoint": "https://api.github.com" | |
| 72 | + } | |
| 73 | + } | |
| 74 | + } | |
| 75 | +} | |
| 76 | +`, | |
| 77 | + "utf8", | |
| 78 | + ), | |
| 79 | + ); | |
| 80 | + | |
| 81 | + const context = yield* Effect.promise(() => | |
| 82 | + resolveLocalWorkspaceContext({ workspaceRoot }), | |
| 83 | + ); | |
| 84 | + const failure = yield* Effect.flip( | |
| 85 | + Effect.tryPromise({ | |
| 86 | + try: () => loadLocalExecutorConfig(context), | |
| 87 | + catch: (cause) => | |
| 88 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 89 | + }), | |
| 90 | + ); | |
| 91 | + | |
| 92 | + expect(failure.message).toContain("Invalid executor config"); | |
| 93 | + expect(failure.message).toContain("line 5, column 7"); | |
| 94 | + }), | |
| 95 | + ); | |
| 96 | +}); | |
| @@ -2,6 +2,11 @@ import { createHash } from "node:crypto"; · 1 unmodified line | ||
| 2 | 2 | import { homedir } from "node:os"; |
| 3 | 3 | import { basename, dirname, isAbsolute, join, resolve } from "node:path"; |
| 4 | 4 | import { promises as fs } from "node:fs"; |
| 5 | +import { | |
| 6 | + type ParseError as JsoncParseError, | |
| 7 | + parse as parseJsoncDocument, | |
| 8 | + printParseErrorCode, | |
| 9 | +} from "jsonc-parser"; | |
| 5 | 10 | |
| 6 | 11 | import { |
| 7 | 12 | LocalExecutorConfigSchema, |
| @@ -49,74 +54,38 @@ const trimOrUndefined = (value: string | undefined | null): string | undefined = · 41 unmodified lines | ||
| 49 | 54 | return trimmed && trimmed.length > 0 ? trimmed : undefined; |
| 50 | 55 | }; |
| 51 | 56 | |
| 52 | −const stripJsonComments = (input: string): string => { | |
| 53 | − let output = ""; | |
| 54 | − let inString = false; | |
| 55 | − let stringChar = '"'; | |
| 56 | − let escapeNext = false; | |
| 57 | − let inLineComment = false; | |
| 58 | − let inBlockComment = false; | |
| 59 | − | |
| 60 | − for (let index = 0; index < input.length; index += 1) { | |
| 61 | − const char = input[index]!; | |
| 62 | − const next = input[index + 1]; | |
| 63 | − | |
| 64 | − if (inLineComment) { | |
| 65 | − if (char === "\n") { | |
| 66 | − inLineComment = false; | |
| 67 | − output += char; | |
| 68 | − } | |
| 69 | − continue; | |
| 70 | − } | |
| 57 | +const formatJsoncParseErrors = (content: string, errors: readonly JsoncParseError[]): string => { | |
| 58 | + const lines = content.split("\n"); | |
| 71 | 59 | |
| 72 | − if (inBlockComment) { | |
| 73 | − if (char === "*" && next === "/") { | |
| 74 | − inBlockComment = false; | |
| 75 | − index += 1; | |
| 76 | − } | |
| 77 | − continue; | |
| 78 | − } | |
| 60 | + return errors | |
| 61 | + .map((error) => { | |
| 62 | + const beforeOffset = content.slice(0, error.offset).split("\n"); | |
| 63 | + const line = beforeOffset.length; | |
| 64 | + const column = beforeOffset[beforeOffset.length - 1]?.length ?? 0; | |
| 65 | + const lineText = lines[line - 1]; | |
| 66 | + const location = `line ${line}, column ${column + 1}`; | |
| 67 | + const detail = printParseErrorCode(error.error); | |
| 79 | 68 | |
| 80 | − if (inString) { | |
| 81 | − output += char; | |
| 82 | − if (escapeNext) { | |
| 83 | − escapeNext = false; | |
| 84 | − } else if (char === "\\") { | |
| 85 | − escapeNext = true; | |
| 86 | − } else if (char === stringChar) { | |
| 87 | − inString = false; | |
| 69 | + if (!lineText) { | |
| 70 | + return `${detail} at ${location}`; | |
| 88 | 71 | } |
| 89 | − continue; | |
| 90 | − } | |
| 91 | − | |
| 92 | − if ((char === '"' || char === "'") && !inString) { | |
| 93 | − inString = true; | |
| 94 | − stringChar = char; | |
| 95 | − output += char; | |
| 96 | − continue; | |
| 97 | − } | |
| 98 | 72 | |
| 99 | − if (char === "/" && next === "/") { | |
| 100 | − inLineComment = true; | |
| 101 | − index += 1; | |
| 102 | − continue; | |
| 103 | − } | |
| 104 | − | |
| 105 | − if (char === "/" && next === "*") { | |
| 106 | − inBlockComment = true; | |
| 107 | − index += 1; | |
| 108 | − continue; | |
| 109 | − } | |
| 110 | − | |
| 111 | − output += char; | |
| 112 | − } | |
| 113 | − | |
| 114 | − return output; | |
| 73 | + return `${detail} at ${location}\n${lineText}`; | |
| 74 | + }) | |
| 75 | + .join("\n"); | |
| 115 | 76 | }; |
| 116 | 77 | |
| 117 | 78 | const parseJsonc = (input: { path: string; content: string }): LocalExecutorConfig => { |
| 79 | + const errors: JsoncParseError[] = []; | |
| 80 | + | |
| 118 | 81 | try { |
| 119 | − const parsed = JSON.parse(stripJsonComments(input.content)) as unknown; | |
| 82 | + const parsed = parseJsoncDocument(input.content, errors, { | |
| 83 | + allowTrailingComma: true, | |
| 84 | + }); | |
| 85 | + if (errors.length > 0) { | |
| 86 | + throw new Error(formatJsoncParseErrors(input.content, errors)); | |
| 87 | + } | |
| 88 | + | |
| 120 | 89 | return decodeLocalExecutorConfig(parsed); |
| 121 | 90 | } catch (cause) { |
| 122 | 91 | const message = cause instanceof Error ? cause.message : String(cause); |