diff --git a/build.mjs b/build.mjs index 13b34f8..61157d2 100644 --- a/build.mjs +++ b/build.mjs @@ -87,6 +87,8 @@ esbuild "typescript", // pino-pretty uses worker threads and must be external "pino-pretty", + // esbuild must be external to access its binary executable + "esbuild", ], }) .catch((e) => { diff --git a/src/core/applications.ts b/src/core/applications.ts index 90c4571..feb666c 100644 --- a/src/core/applications.ts +++ b/src/core/applications.ts @@ -123,6 +123,17 @@ export interface ReleaseInfo { createdAt: string; } +/** + * UI extension format for release API (single target instead of array) + */ +export interface ReleaseUiExtension { + name: string; + handle: string; + source: string; + type: string; + target?: string; +} + export interface ExtensionSecurityReport { extensionName: string; extensionDir: string; @@ -848,9 +859,10 @@ export function applicationReleaseEffect( ); } - // Load configuration to get actions and subscriptions + // Load configuration to get actions, subscriptions, and extensions let actions: ActionConfig[] = []; let subscriptions: SubscriptionConfig[] = []; + let uiExtensions: ReleaseUiExtension[] = []; const configResult = yield* getConfigFileEffect({ configPath: input.configPath, @@ -860,6 +872,32 @@ export function applicationReleaseEffect( if (configResult && !isConfigValidationErrorResult(configResult)) { actions = configResult.actions || []; subscriptions = configResult.subscriptions?.webhook || []; + + // Extract UI extensions from config + const extensions = yield* getExtensionsFromConfigEffect({ + configPath: input.configPath, + env: input.env as Environment, + }).pipe(Effect.orElseSucceed(() => [])); + + // Validate that only one target is specified per extension (API limitation) + for (const ext of extensions) { + if (ext.targets && ext.targets.length > 1) { + yield* Effect.fail( + new ValidationError({ + message: `UI extension "${ext.name}" has ${ext.targets.length} targets, but only one target is supported per extension during release`, + }), + ); + } + } + + // Map extensions to release format + uiExtensions = extensions.map((ext) => ({ + name: ext.name, + handle: ext.handle, + source: ext.source, + type: ext.type, + target: ext.targets?.[0]?.target, + })); } const releaseData = { @@ -868,6 +906,7 @@ export function applicationReleaseEffect( description: input.description, actions, subscriptions, + uiExtensions, }; const result = yield* callCreateRelease(releaseData, { accessToken }); diff --git a/src/services/applications.ts b/src/services/applications.ts index 8d966fa..871a13b 100644 --- a/src/services/applications.ts +++ b/src/services/applications.ts @@ -108,6 +108,14 @@ export const CreateReleaseMutation = graphql(` version description createdAt + uiExtensions { + id + name + handle + type + source + target + } } } `); @@ -167,12 +175,22 @@ export const subscriptionInput = type({ url: "string", }); +export const uiExtensionInput = type({ + name: "string", + handle: "string", + source: "string", + // Must match ExtensionType from src/core/extension/bundler-config.ts + type: '"embed" | "checkout" | "blocks"', + target: "string?", +}); + export const releaseInput = type({ applicationId: "string", version: "string", description: "string?", actions: actionInput.array().optional(), subscriptions: subscriptionInput.array().optional(), + uiExtensions: uiExtensionInput.array().optional(), }); export function createApplicationEffect( diff --git a/src/services/extension/bundler.ts b/src/services/extension/bundler.ts index 802dbcc..69fb6b3 100644 --- a/src/services/extension/bundler.ts +++ b/src/services/extension/bundler.ts @@ -214,20 +214,35 @@ export function bundleExtensionEffect( // Run esbuild const buildResult = yield* Effect.tryPromise({ try: () => esbuild.build(config), - catch: (error) => - new ConfigurationError({ - message: `ESBUILD_ERROR: ${error instanceof Error ? error.message : String(error)}`, - userMessage: "Failed to bundle extension", - }), + catch: (error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ + type: "esbuild_error", + extension: pkg.name, + entryPath, + config, + error: errorMessage, + }); + return new ConfigurationError({ + message: `ESBUILD_ERROR: ${errorMessage}`, + userMessage: `Failed to bundle extension: ${errorMessage}`, + }); + }, }); // Extract output files const outputFiles = buildResult.outputFiles; if (!outputFiles || outputFiles.length === 0) { + logger.error({ + type: "bundle_error", + extension: pkg.name, + reason: "no_output_files", + buildResult, + }); return yield* Effect.fail( new ConfigurationError({ message: "No output files generated by esbuild", - userMessage: "Failed to bundle extension", + userMessage: "Failed to bundle extension: No output files generated by esbuild", }), ); } @@ -236,10 +251,17 @@ export function bundleExtensionEffect( const mapFile = outputFiles.find((f) => f.path.endsWith(".mjs.map")); if (!mjsFile) { + const fileList = outputFiles.map(f => f.path).join(", "); + logger.error({ + type: "bundle_error", + extension: pkg.name, + reason: "no_mjs_file", + outputFiles: fileList, + }); return yield* Effect.fail( new ConfigurationError({ - message: "No .mjs file generated by esbuild", - userMessage: "Failed to bundle extension", + message: `No .mjs file generated by esbuild. Output files: ${fileList}`, + userMessage: `Failed to bundle extension: No .mjs file generated. Got: ${fileList}`, }), ); } diff --git a/tests/integration/application-service.test.ts b/tests/integration/application-service.test.ts index 3b1ea78..85d6f05 100644 --- a/tests/integration/application-service.test.ts +++ b/tests/integration/application-service.test.ts @@ -132,6 +132,44 @@ describe("Application Service", () => { expect(release?.createRelease?.createdAt).toBeTruthy(); }); + test("creates application release with UI extensions", async () => { + const input = { + applicationId: "app-1", + version: "2.1.0", + description: "Release with UI extensions", + actions: [], + subscriptions: [], + uiExtensions: [ + { + name: "product-embed", + handle: "product-embed-handle", + source: "./extensions/embed/index.tsx", + type: "embed", + target: "products.view", + }, + { + name: "checkout-extension", + handle: "checkout-ext-handle", + source: "./extensions/checkout/index.tsx", + type: "checkout", + target: "checkout.summary", + }, + ], + }; + + const release = await runEffect( + createReleaseEffect(input, { + accessToken: "test-token-123", + }), + ); + + expect(release?.createRelease?.version).toBe(input.version); + expect(release?.createRelease?.description).toBe(input.description); + expect(release?.createRelease?.id).toBeTruthy(); + expect(release?.createRelease?.createdAt).toBeTruthy(); + expect(release?.createRelease?.uiExtensions).toBeDefined(); + }); + test("enables application", async () => { const result = await runEffect( enableApplicationEffect( diff --git a/tests/setup/fixtures/application-fixtures.ts b/tests/setup/fixtures/application-fixtures.ts index 9182293..00bdfb0 100644 --- a/tests/setup/fixtures/application-fixtures.ts +++ b/tests/setup/fixtures/application-fixtures.ts @@ -71,10 +71,21 @@ export const applicationFixtures = { publicKey: "public-key-data", }), - createReleaseResponse: (input: { version: string; description: string }) => ({ + createReleaseResponse: (input: { + version: string; + description: string; + uiExtensions?: Array<{ + name: string; + handle: string; + source: string; + type: string; + target?: string; + }>; + }) => ({ id: `release-${Date.now()}`, version: input.version, description: input.description, createdAt: new Date().toISOString(), + uiExtensions: input.uiExtensions || [], }), };