@effijs/forge 0.0.0-stage → 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ import { Command } from "commander";
2
+ import inquirer from "inquirer";
3
+ import fs from "fs/promises";
4
+ import path from "path";
5
+ import chalk from "chalk";
6
+ import ora from "ora";
7
+ import { Forge } from "../Forge.js";
8
+ import { discoverGenerators } from "../config/resolveGenerator.js";
9
+ import { scaffoldLocalGenerator } from "./scaffoldLocal.js";
10
+ import { parseUnknownFlags } from "./parseArgv.js";
11
+
12
+ export class ForgeCLI {
13
+ constructor() {
14
+ this.program = new Command();
15
+ this.forge = null;
16
+ }
17
+
18
+ async init(cwd = process.cwd()) {
19
+ this.forge = await Forge.create(cwd);
20
+
21
+ this.program
22
+ .name("forge")
23
+ .description("A modern scaffolding tool for generating code and projects")
24
+ .version("1.0.0");
25
+
26
+ const generateCmd = this.program
27
+ .command("generate <generator>")
28
+ .alias("g")
29
+ .description("Run a generator")
30
+ .option("--dry-run", "Preview without writing files")
31
+ .option("--force", "Overwrite existing files")
32
+ .option("--yes", "Skip prompts and use defaults")
33
+ .option("--name <name>", "Pass name directly")
34
+ .option("--path <path>", "Pass path directly")
35
+ .allowUnknownOption()
36
+ .action(async (generator, options) => {
37
+ const runOptions = {
38
+ dryRun: options.dryRun,
39
+ force: options.force,
40
+ yes: options.yes,
41
+ };
42
+ if (options.name) runOptions.name = options.name;
43
+ if (options.path) runOptions.path = options.path;
44
+
45
+ Object.assign(runOptions, parseUnknownFlags(this.argv ?? process.argv));
46
+
47
+ await this.forge.run(generator, runOptions);
48
+ });
49
+
50
+ this.program
51
+ .command("list")
52
+ .alias("ls")
53
+ .description("List available generators")
54
+ .action(async () => {
55
+ const spinner = ora("Loading generators...").start();
56
+
57
+ try {
58
+ const catalog = await discoverGenerators(this.forge.config);
59
+ spinner.stop();
60
+
61
+ const byNamespace = new Map();
62
+ for (const [id, meta] of catalog) {
63
+ const ns = meta.namespace ?? id.split(":")[0];
64
+ if (!byNamespace.has(ns)) byNamespace.set(ns, []);
65
+ byNamespace.get(ns).push({ id, meta });
66
+ }
67
+
68
+ console.log(chalk.bold.cyan("\nšŸ“¦ Available generators:\n"));
69
+
70
+ for (const [ns, items] of byNamespace) {
71
+ const label =
72
+ ns === "local"
73
+ ? "Local"
74
+ : `Extended (${ns})`;
75
+ console.log(chalk.bold(label + ":"));
76
+ for (const { id, meta } of items) {
77
+ const shortName = id.split(":")[1] ?? id;
78
+ const desc = meta.description ? chalk.dim(` — ${meta.description}`) : "";
79
+ console.log(` • ${chalk.green(shortName)}${desc}`);
80
+ }
81
+ console.log("");
82
+ }
83
+
84
+ const aliases = this.forge.config.aliases ?? {};
85
+ const aliasEntries = Object.entries(aliases);
86
+ if (aliasEntries.length) {
87
+ console.log(chalk.bold("Aliases:"));
88
+ for (const [alias, target] of aliasEntries) {
89
+ console.log(` ${chalk.yellow(alias)} → ${target}`);
90
+ }
91
+ console.log("");
92
+ }
93
+ } catch (error) {
94
+ spinner.fail("Failed to list generators");
95
+ console.error(chalk.red(error.message));
96
+ }
97
+ });
98
+
99
+ this.program
100
+ .command("init")
101
+ .description("Initialize Forge configuration and local directories")
102
+ .action(async () => {
103
+ const answers = await inquirer.prompt([
104
+ {
105
+ type: "input",
106
+ name: "templatesDir",
107
+ message: "Local templates directory:",
108
+ default: "./forge/templates",
109
+ },
110
+ {
111
+ type: "input",
112
+ name: "generatorsDir",
113
+ message: "Local generators directory:",
114
+ default: "./forge/generators",
115
+ },
116
+ {
117
+ type: "input",
118
+ name: "outputDir",
119
+ message: "Default output directory:",
120
+ default: "./src",
121
+ },
122
+ {
123
+ type: "list",
124
+ name: "templateEngine",
125
+ message: "Template engine:",
126
+ choices: ["handlebars", "eta"],
127
+ default: "handlebars",
128
+ },
129
+ {
130
+ type: "confirm",
131
+ name: "usePreset",
132
+ message: "Use @effijs/templates preset (react-native-fsd)?",
133
+ default: true,
134
+ },
135
+ ]);
136
+
137
+ const config = {
138
+ outputDir: answers.outputDir,
139
+ templateEngine: answers.templateEngine,
140
+ local: {
141
+ templatesDir: answers.templatesDir,
142
+ generatorsDir: answers.generatorsDir,
143
+ },
144
+ };
145
+
146
+ if (answers.usePreset) {
147
+ config.preset = "react-native-fsd";
148
+ config.presetMap = {
149
+ "rn-fsd": "@effijs/templates/presets/react-native-fsd",
150
+ };
151
+ config.primaryPreset = "rn-fsd";
152
+ config.generators = ["rn-fsd:component"];
153
+ config.aliases = { comp: "rn-fsd:component" };
154
+ }
155
+
156
+ const configContent = `export default ${JSON.stringify(config, null, 2)};\n`;
157
+ await fs.writeFile("./forge.config.js", configContent);
158
+
159
+ await fs.mkdir(answers.templatesDir, { recursive: true });
160
+ await fs.mkdir(answers.generatorsDir, { recursive: true });
161
+
162
+ console.log(chalk.green("\nāœ… Created forge.config.js and ./forge/ directories\n"));
163
+ });
164
+
165
+ const newCmd = this.program
166
+ .command("new")
167
+ .description("Scaffold new resources");
168
+
169
+ newCmd
170
+ .command("local <name>")
171
+ .description("Create a local generator with example templates")
172
+ .option("--type <type>", "Variant: service | api | component", "service")
173
+ .option("--force", "Overwrite existing generator")
174
+ .option("--no-register", "Skip auto-register in forge.config.js")
175
+ .action(async (name, options) => {
176
+ await scaffoldLocalGenerator({
177
+ name,
178
+ type: options.type,
179
+ force: options.force,
180
+ register: options.register !== false,
181
+ });
182
+ });
183
+ }
184
+
185
+ run(argv = process.argv) {
186
+ this.argv = argv;
187
+ this.program.parse(argv);
188
+ }
189
+ }
@@ -0,0 +1,50 @@
1
+ const RESERVED_FLAGS = new Set(["dry-run", "force", "yes", "name", "path"]);
2
+
3
+ function camelCaseFlag(key) {
4
+ return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
5
+ }
6
+
7
+ function coerceFlagValue(value) {
8
+ if (value === "true") return true;
9
+ if (value === "false") return false;
10
+ return value;
11
+ }
12
+
13
+ /**
14
+ * Collect generator data from unknown long flags.
15
+ * Reserved run flags stay with the commander options that already parsed them.
16
+ */
17
+ export function parseUnknownFlags(argv) {
18
+ const data = {};
19
+ const args = Array.isArray(argv) ? argv : [];
20
+
21
+ for (let i = 0; i < args.length; i++) {
22
+ const arg = args[i];
23
+ if (arg === "--") break;
24
+ if (!arg.startsWith("--")) continue;
25
+
26
+ const body = arg.slice(2);
27
+ let key;
28
+ let value;
29
+ const eq = body.indexOf("=");
30
+
31
+ if (eq !== -1) {
32
+ key = body.slice(0, eq);
33
+ value = body.slice(eq + 1);
34
+ } else {
35
+ key = body;
36
+ const next = args[i + 1];
37
+ if (next !== undefined && !next.startsWith("-")) {
38
+ value = next;
39
+ i++;
40
+ } else {
41
+ value = true;
42
+ }
43
+ }
44
+
45
+ if (!key || RESERVED_FLAGS.has(key)) continue;
46
+ data[camelCaseFlag(key)] = coerceFlagValue(value);
47
+ }
48
+
49
+ return data;
50
+ }
@@ -0,0 +1,228 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import chalk from "chalk";
4
+ import * as changeCase from "change-case";
5
+
6
+ const TYPE_VARIANTS = {
7
+ service: {
8
+ files: [
9
+ {
10
+ name: "{{kebabCase name}}.service.ts.hbs",
11
+ content: `export class {{pascalCase name}}Service {
12
+ async execute(): Promise<void> {
13
+ // TODO: implement {{camelCase name}} service
14
+ }
15
+ }
16
+ `,
17
+ },
18
+ {
19
+ name: "{{kebabCase name}}.types.ts.hbs",
20
+ content: `export interface {{pascalCase name}}Config {
21
+ // TODO: define config
22
+ }
23
+ `,
24
+ },
25
+ {
26
+ name: "index.ts.hbs",
27
+ content: `export * from './{{kebabCase name}}.service';\nexport * from './{{kebabCase name}}.types';\n`,
28
+ },
29
+ ],
30
+ actions: `[
31
+ {
32
+ type: "add",
33
+ path: "{{kebabCase name}}/{{kebabCase name}}.service.ts",
34
+ templateFile: "{{kebabCase name}}/{{kebabCase name}}.service.ts.hbs",
35
+ },
36
+ {
37
+ type: "add",
38
+ path: "{{kebabCase name}}/{{kebabCase name}}.types.ts",
39
+ templateFile: "{{kebabCase name}}/{{kebabCase name}}.types.ts.hbs",
40
+ },
41
+ {
42
+ type: "add",
43
+ path: "{{kebabCase name}}/index.ts",
44
+ templateFile: "{{kebabCase name}}/index.ts.hbs",
45
+ },
46
+ ]`,
47
+ },
48
+ api: {
49
+ files: [
50
+ {
51
+ name: "{{kebabCase name}}.api.ts.hbs",
52
+ content: `export async function fetch{{pascalCase name}}(): Promise<unknown> {
53
+ // TODO: implement API client
54
+ return null;
55
+ }
56
+ `,
57
+ },
58
+ {
59
+ name: "{{kebabCase name}}.types.ts.hbs",
60
+ content: `export type {{pascalCase name}}Response = Record<string, unknown>;\n`,
61
+ },
62
+ {
63
+ name: "index.ts.hbs",
64
+ content: `export * from './{{kebabCase name}}.api';\n`,
65
+ },
66
+ ],
67
+ actions: `[
68
+ {
69
+ type: "add",
70
+ path: "api/{{kebabCase name}}.api.ts",
71
+ templateFile: "{{kebabCase name}}/{{kebabCase name}}.api.ts.hbs",
72
+ },
73
+ {
74
+ type: "add",
75
+ path: "api/{{kebabCase name}}.types.ts",
76
+ templateFile: "{{kebabCase name}}/{{kebabCase name}}.types.ts.hbs",
77
+ },
78
+ ]`,
79
+ },
80
+ component: {
81
+ files: [
82
+ {
83
+ name: "{{pascalCase name}}.tsx.hbs",
84
+ content: `import React from 'react';
85
+
86
+ export function {{pascalCase name}}() {
87
+ return <div>{{pascalCase name}}</div>;
88
+ }
89
+ `,
90
+ },
91
+ {
92
+ name: "index.ts.hbs",
93
+ content: `export { {{pascalCase name}} } from './{{pascalCase name}}';\n`,
94
+ },
95
+ ],
96
+ actions: `[
97
+ {
98
+ type: "add",
99
+ path: "components/{{pascalCase name}}/{{pascalCase name}}.tsx",
100
+ templateFile: "{{kebabCase name}}/{{pascalCase name}}.tsx.hbs",
101
+ },
102
+ {
103
+ type: "add",
104
+ path: "components/{{pascalCase name}}/index.ts",
105
+ templateFile: "{{kebabCase name}}/index.ts.hbs",
106
+ },
107
+ ]`,
108
+ },
109
+ };
110
+
111
+ export async function scaffoldLocalGenerator(options = {}) {
112
+ const {
113
+ name,
114
+ type = "service",
115
+ description = "",
116
+ force = false,
117
+ register = true,
118
+ cwd = process.cwd(),
119
+ } = options;
120
+
121
+ const kebabName = changeCase.kebabCase(name);
122
+ const variant = TYPE_VARIANTS[type] ?? TYPE_VARIANTS.service;
123
+
124
+ const generatorsDir = path.join(cwd, "forge", "generators", kebabName);
125
+ const templatesDir = path.join(cwd, "forge", "templates", kebabName);
126
+
127
+ try {
128
+ await fs.access(generatorsDir);
129
+ if (!force) {
130
+ throw new Error(
131
+ `Generator "${kebabName}" already exists. Use --force to overwrite.`,
132
+ );
133
+ }
134
+ } catch (error) {
135
+ if (error.code !== "ENOENT") throw error;
136
+ }
137
+
138
+ await fs.mkdir(generatorsDir, { recursive: true });
139
+ await fs.mkdir(templatesDir, { recursive: true });
140
+
141
+ for (const file of variant.files) {
142
+ const fileName = file.name
143
+ .replace(/\{\{kebabCase name\}\}/g, kebabName)
144
+ .replace(/\{\{pascalCase name\}\}/g, changeCase.pascalCase(name));
145
+ const filePath = path.join(templatesDir, fileName);
146
+ const content = file.content
147
+ .replace(/\{\{pascalCase name\}\}/g, changeCase.pascalCase(name))
148
+ .replace(/\{\{camelCase name\}\}/g, changeCase.camelCase(name))
149
+ .replace(/\{\{kebabCase name\}\}/g, kebabName);
150
+ await fs.writeFile(filePath, content);
151
+ }
152
+
153
+ const generatorJs = `export default {
154
+ description: "${description || `Local ${type} generator: ${kebabName}`}",
155
+ prompts: [
156
+ {
157
+ type: "input",
158
+ name: "name",
159
+ message: "Name:",
160
+ default: "${changeCase.pascalCase(name)}",
161
+ },
162
+ ],
163
+ actions: ${variant.actions},
164
+ };
165
+ `;
166
+
167
+ await fs.writeFile(path.join(generatorsDir, "generator.js"), generatorJs);
168
+
169
+ const readme = `# ${kebabName}
170
+
171
+ Local Forge generator (${type}).
172
+
173
+ ## Run
174
+
175
+ \`\`\`bash
176
+ forge g local:${kebabName}
177
+ \`\`\`
178
+
179
+ ## Customize
180
+
181
+ - Edit \`forge/generators/${kebabName}/generator.js\`
182
+ - Edit templates in \`forge/templates/${kebabName}/\`
183
+ `;
184
+
185
+ await fs.writeFile(path.join(generatorsDir, "README.md"), readme);
186
+
187
+ if (register) {
188
+ await registerInConfig(cwd, kebabName);
189
+ }
190
+
191
+ console.log(chalk.green(`\nāœ… Created local generator "${kebabName}"`));
192
+ console.log(chalk.dim(` forge/generators/${kebabName}/`));
193
+ console.log(chalk.dim(` forge/templates/${kebabName}/\n`));
194
+
195
+ return { name: kebabName, generatorsDir, templatesDir };
196
+ }
197
+
198
+ async function registerInConfig(cwd, kebabName) {
199
+ const configPath = path.join(cwd, "forge.config.js");
200
+ const entry = `local:${kebabName}`;
201
+
202
+ try {
203
+ let content = await fs.readFile(configPath, "utf-8");
204
+
205
+ if (content.includes(entry)) return;
206
+
207
+ if (content.includes("generators:")) {
208
+ content = content.replace(
209
+ /generators:\s*\[/,
210
+ `generators: [\n "${entry}",`,
211
+ );
212
+ } else {
213
+ content = content.replace(
214
+ /export default \{/,
215
+ `export default {\n generators: ["${entry}"],`,
216
+ );
217
+ }
218
+
219
+ await fs.writeFile(configPath, content);
220
+ console.log(chalk.dim(` Registered "${entry}" in forge.config.js`));
221
+ } catch {
222
+ console.log(
223
+ chalk.yellow(
224
+ ` Add "${entry}" to generators in forge.config.js manually`,
225
+ ),
226
+ );
227
+ }
228
+ }
@@ -0,0 +1,52 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { pathToFileURL } from "url";
4
+
5
+ const CONFIG_FILES = [
6
+ "forge.config.js",
7
+ "forge.config.mjs",
8
+ ".forgerc.js",
9
+ ".forgerc.json",
10
+ ];
11
+
12
+ export const BASE_DEFAULTS = {
13
+ templatesDir: "./templates",
14
+ outputDir: "./src",
15
+ templateEngine: "handlebars",
16
+ helpers: {},
17
+ };
18
+
19
+ /**
20
+ * Load project forge config from cwd.
21
+ * @returns {Promise<{ config: object, configFileDir: string | null }>}
22
+ */
23
+ export async function loadLocalConfig(cwd = process.cwd()) {
24
+ for (const configFile of CONFIG_FILES) {
25
+ const fullPath = path.resolve(cwd, configFile);
26
+
27
+ try {
28
+ await fs.access(fullPath);
29
+
30
+ if (configFile.endsWith(".json")) {
31
+ const content = await fs.readFile(fullPath, "utf-8");
32
+ return {
33
+ config: JSON.parse(content),
34
+ configFileDir: path.dirname(fullPath),
35
+ };
36
+ }
37
+
38
+ const module = await import(pathToFileURL(fullPath).href);
39
+ return {
40
+ config: module.default ?? module,
41
+ configFileDir: path.dirname(fullPath),
42
+ };
43
+ } catch (error) {
44
+ if (error.code === "ENOENT") continue;
45
+ throw new Error(
46
+ `Failed to load config "${configFile}": ${error.message}`,
47
+ );
48
+ }
49
+ }
50
+
51
+ return { config: {}, configFileDir: null };
52
+ }
@@ -0,0 +1,85 @@
1
+ import path from "path";
2
+ import { pathToFileURL, fileURLToPath } from "url";
3
+ import fs from "fs/promises";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ function defaultResolve(specifier) {
8
+ return import.meta.resolve(specifier);
9
+ }
10
+
11
+ async function finalizeModulePath(absPath) {
12
+ let stat = null;
13
+ try {
14
+ stat = await fs.stat(absPath);
15
+ } catch {
16
+ stat = null;
17
+ }
18
+
19
+ if (stat?.isFile()) return absPath;
20
+ if (stat?.isDirectory()) return path.join(absPath, "index.js");
21
+ if (path.extname(absPath)) return absPath;
22
+ return path.join(absPath, "index.js");
23
+ }
24
+
25
+ /**
26
+ * Resolve a preset reference to the module file that should be imported.
27
+ * Absolute paths and `./` / `../` are files or directories on disk.
28
+ * Any other specifier goes through Node resolution, then a monorepo fallback.
29
+ */
30
+ export async function resolvePresetPath(
31
+ presetRef,
32
+ presetMap = {},
33
+ resolveImport = defaultResolve,
34
+ ) {
35
+ if (presetMap[presetRef]) {
36
+ return resolvePresetPath(presetMap[presetRef], {}, resolveImport);
37
+ }
38
+
39
+ if (path.isAbsolute(presetRef)) {
40
+ return finalizeModulePath(presetRef);
41
+ }
42
+
43
+ if (presetRef.startsWith("./") || presetRef.startsWith("../")) {
44
+ return finalizeModulePath(path.resolve(process.cwd(), presetRef));
45
+ }
46
+
47
+ try {
48
+ const resolved = resolveImport(presetRef);
49
+ const filePath = String(resolved).startsWith("file:")
50
+ ? fileURLToPath(resolved)
51
+ : resolved;
52
+ return finalizeModulePath(filePath);
53
+ } catch {
54
+ // Package resolution failed; try the checkout next to Forge, then cwd.
55
+ }
56
+
57
+ const templatesPkg = path.resolve(__dirname, "../../../templates");
58
+ const fromTemplates = path.join(templatesPkg, "presets", presetRef);
59
+ try {
60
+ await fs.access(fromTemplates);
61
+ return finalizeModulePath(fromTemplates);
62
+ } catch {
63
+ return finalizeModulePath(path.resolve(process.cwd(), presetRef));
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Load a preset config module.
69
+ */
70
+ export async function loadPreset(presetRef, presetMap = {}) {
71
+ const modulePath = await resolvePresetPath(presetRef, presetMap);
72
+
73
+ try {
74
+ const module = await import(pathToFileURL(modulePath).href);
75
+ const config = module.default ?? module;
76
+ const presetDir = path.dirname(modulePath);
77
+ return {
78
+ ...config,
79
+ id: config.id ?? path.basename(presetDir),
80
+ _presetDir: presetDir,
81
+ };
82
+ } catch (error) {
83
+ throw new Error(`Failed to load preset "${presetRef}": ${error.message}`);
84
+ }
85
+ }
@@ -0,0 +1,104 @@
1
+ import path from "path";
2
+ import { BASE_DEFAULTS, loadLocalConfig } from "./loadConfig.js";
3
+ import { loadPreset } from "./loadPreset.js";
4
+ import { deepMerge } from "../utils/deepMerge.js";
5
+ import { resolveForgePaths } from "../utils/resolvePaths.js";
6
+
7
+ function resolvePresetIds(config) {
8
+ if (config.presets?.length) return config.presets;
9
+ if (config.preset) {
10
+ const id =
11
+ typeof config.preset === "string" && !config.preset.includes("/")
12
+ ? config.preset
13
+ : path.basename(config.preset);
14
+ return [id];
15
+ }
16
+ return [];
17
+ }
18
+
19
+ function buildTemplateRoots(presets, paths, localConfig) {
20
+ const roots = [];
21
+
22
+ for (const preset of presets) {
23
+ const templatesDir = preset.templatesDir
24
+ ? path.isAbsolute(preset.templatesDir)
25
+ ? preset.templatesDir
26
+ : path.resolve(preset._presetDir, preset.templatesDir)
27
+ : path.resolve(preset._presetDir, "../../generators");
28
+
29
+ roots.push({
30
+ id: preset.id,
31
+ namespace: preset.id,
32
+ templatesDir,
33
+ generatorsDir: templatesDir,
34
+ generators: preset.generators ?? {},
35
+ });
36
+ }
37
+
38
+ roots.push({
39
+ id: "local",
40
+ namespace: "local",
41
+ templatesDir: paths.localTemplatesDir,
42
+ generatorsDir: paths.localGeneratorsDir,
43
+ generators: {},
44
+ });
45
+
46
+ return roots;
47
+ }
48
+
49
+ function collectGeneratorCatalog(templateRoots) {
50
+ const catalog = new Map();
51
+
52
+ for (const root of templateRoots) {
53
+ if (root.generators && typeof root.generators === "object") {
54
+ for (const [name, meta] of Object.entries(root.generators)) {
55
+ const namespacedId = `${root.namespace}:${name}`;
56
+ catalog.set(namespacedId, {
57
+ id: namespacedId,
58
+ name,
59
+ namespace: root.namespace,
60
+ root,
61
+ ...meta,
62
+ });
63
+ }
64
+ }
65
+ }
66
+
67
+ return catalog;
68
+ }
69
+
70
+ /**
71
+ * Merge base → extended presets → local project config.
72
+ */
73
+ export async function mergeConfig(cwd = process.cwd()) {
74
+ const { config: localConfig, configFileDir } = await loadLocalConfig(cwd);
75
+
76
+ let merged = deepMerge({}, BASE_DEFAULTS);
77
+ const presetIds = resolvePresetIds(localConfig);
78
+ const presetMap = localConfig.presetMap ?? {};
79
+ const loadedPresets = [];
80
+
81
+ for (const presetId of presetIds) {
82
+ const presetRef = presetMap[presetId] ?? presetId;
83
+ const preset = await loadPreset(presetRef, presetMap);
84
+ preset.id = preset.id ?? presetId;
85
+ loadedPresets.push(preset);
86
+ merged = deepMerge(merged, preset);
87
+ }
88
+
89
+ merged = deepMerge(merged, localConfig);
90
+ merged.configFileDir = configFileDir;
91
+ merged.primaryPreset =
92
+ merged.primaryPreset ?? loadedPresets[0]?.id ?? null;
93
+
94
+ const paths = resolveForgePaths(merged, configFileDir);
95
+ merged.paths = paths;
96
+ merged.templateRoots = buildTemplateRoots(
97
+ loadedPresets,
98
+ paths,
99
+ localConfig.local,
100
+ );
101
+ merged.generatorCatalog = collectGeneratorCatalog(merged.templateRoots);
102
+
103
+ return merged;
104
+ }