/* * Copyright 2025 the original author or authors. *

* Licensed under the Moderne Source Available License (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* https://docs.moderne.io/licensing/moderne-source-available-license *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {Recipe, RecipeDescriptor} from "./recipe"; export type RecipeConstructor = new (options?: any) => Recipe; function isRecipeConstructor(recipe: RecipeConstructor | RecipeDescriptor): recipe is RecipeConstructor { return typeof recipe === 'function'; } export class RecipeMarketplace { readonly root: RecipeMarketplace.Category = new RecipeMarketplace.Category({ displayName: "Root", description: "This is the root of all categories. When displaying the category hierarchy of a marketplace, this is typically not shown." }); /** * Install a recipe into the marketplace under the specified category path. * If a RecipeConstructor is provided, it is instantiated to extract its descriptor. * If a RecipeDescriptor is provided, it is used directly (for client-side hydration). * Categories are specified top-down (shallowest to deepest). * Intermediate categories are created as needed. * * @param recipe The recipe class or descriptor to install * @param categoryPath Category path from shallowest to deepest (e.g., ["Java", "Search"]) */ public async install( recipe: RecipeConstructor | RecipeDescriptor, categoryPath: CategoryDescriptor[] ): Promise { await this.root.install(recipe, categoryPath); } public categories(): RecipeMarketplace.Category[] { return this.root.categories; } public findRecipe(name: string): [RecipeDescriptor, RecipeConstructor | undefined] | undefined { return this.root.findRecipe(name) } public allRecipes(): RecipeDescriptor[] { return this.root.allRecipes() } } export namespace RecipeMarketplace { export class Category { readonly categories: Category[] = []; readonly recipes: Map = new Map(); constructor( readonly descriptor: CategoryDescriptor, ) { } /** * Install a recipe into this category or a subcategory. * If a RecipeConstructor is provided, it is instantiated to extract its descriptor. * If a RecipeDescriptor is provided, it is used directly (for client-side hydration). * Categories are specified top-down (shallowest to deepest). * Intermediate categories are created as needed. * * @param recipe The recipe class or descriptor to install * @param categoryPath Category path from shallowest to deepest */ public async install( recipe: RecipeConstructor | RecipeDescriptor, categoryPath: CategoryDescriptor[] ): Promise { if (categoryPath.length === 0) { if (isRecipeConstructor(recipe)) { try { const recipeInst = new recipe({}); this.recipes.set(await recipeInst.descriptor(), recipe); } catch (e) { // Surface the underlying cause inline: it is dropped at the // JSON-RPC serialization boundary (only `message` survives), so // folding it into the message is the only way it reaches the // caller's logs. Without this, a failure deeper in the recipe // (e.g. a sub-recipe needing an RPC connection) is hidden behind // the generic "constructor" hint. See gh-7968. const cause = e instanceof Error ? e.message : String(e); const err = new Error(`Failed to install recipe '${recipe.name}'. Ensure the constructor can be called without any arguments. Cause: ${cause}`); (err as any).cause = e; throw err; } } else { this.recipes.set(recipe, undefined); } return; } // Get the first category in the path const firstCategory = categoryPath[0]; const targetCategory = this.findOrCreateCategory(firstCategory); // Recursively add to the child category await targetCategory.install(recipe, categoryPath.slice(1)); } private findOrCreateCategory(categoryDescriptor: CategoryDescriptor): Category { for (const category of this.categories) { if (category.descriptor.displayName === categoryDescriptor.displayName) { return category; } } const newCategory = new Category(categoryDescriptor); this.categories.push(newCategory); return newCategory; } public findRecipe(name: string): [RecipeDescriptor, RecipeConstructor | undefined] | undefined { for (const [recipe, ctor] of this.recipes.entries()) { if (recipe.name === name) { return [recipe, ctor]; } } for (const category of this.categories) { const found = category.findRecipe(name); if (found) { return found; } } return undefined; } public allRecipes(): RecipeDescriptor[] { const result: RecipeDescriptor[] = [...this.recipes.keys()]; for (const category of this.categories) { result.push(...category.allRecipes()); } return result; } } } export const JavaScript: CategoryDescriptor[] = [{displayName: "JavaScript"}] export interface CategoryDescriptor { displayName: string, description?: string }