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

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.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ (The MIT License)
2
+
3
+ Copyright (c) 2020-2026 Yevhenii Kyrychok
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ 'Software'), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
20
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
22
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,234 @@
1
- # Temporary Holding Version
1
+ # @effijs/forge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Forge is a Node.js **ESM scaffolding engine** for EffiJS projects — a modern alternative to [Plop.js](https://plopjs.com/). It runs generators and templates; it does **not** ship them. Templates and extended presets live in [`@effijs/templates`](../templates); project-specific generators live in `./forge/`.
4
+
5
+ ## Documentation
6
+
7
+ Full documentation lives in [`docs/`](./docs) and is built with VitePress:
8
+
9
+ ```bash
10
+ npm run docs:dev
11
+ ```
12
+
13
+ | Section | Contents |
14
+ | ------- | -------- |
15
+ | [Guide](./docs/guide/index.md) | [Getting Started](./docs/guide/getting-started.md), [Core Concepts](./docs/guide/core-concepts.md), [Coming from Plop.js](./docs/guide/vs-plop.md) |
16
+ | [CLI](./docs/cli/index.md) | [`init`](./docs/cli/init.md), [`list`](./docs/cli/list.md), [`generate`](./docs/cli/generate.md), [`new local`](./docs/cli/new-local.md) |
17
+ | [Core API](./docs/core/index.md) | [`Forge`](./docs/core/forge.md), [`ForgeCLI`](./docs/core/forge-cli.md), [Configuration](./docs/core/configuration.md), [Generators](./docs/core/generators.md), [Actions](./docs/core/actions.md), [Presets](./docs/core/presets.md), [Template Engines](./docs/core/template-engines.md) |
18
+ | [Examples](./docs/examples/index.md) | [React Native FSD](./docs/examples/react-native-fsd.md), [Local Generator](./docs/examples/local-generator.md), [Custom Actions](./docs/examples/custom-actions.md), [Multi-Preset Monorepo](./docs/examples/monorepo-multi-preset.md) |
19
+
20
+ The rest of this file is a condensed quick reference.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install @effijs/forge @effijs/templates
26
+ ```
27
+
28
+ Requires Node.js **≥ 18**.
29
+
30
+ ## Quick start
31
+
32
+ ```bash
33
+ forge init
34
+ forge list
35
+ forge g rn-fsd:component
36
+ forge g comp # via alias
37
+ ```
38
+
39
+ ## Forge vs Plop.js
40
+
41
+ Both tools follow the same mental model: **prompts → actions → files**. Forge keeps Plop-compatible action shapes (`add`, `addMany`, `modify`, `append`, `custom`, `skip`) but changes how configuration, templates, and CLI resolution work.
42
+
43
+ | Topic | Plop.js | Forge |
44
+ | ----- | ------- | ----- |
45
+ | **Role** | All-in-one: CLI + generators in one `plopfile` | Engine only; templates in packages or `./forge/` |
46
+ | **Module system** | Historically CJS (`plopfile.js`); ESM in newer versions | Native ESM (`type: "module"`) |
47
+ | **Config** | Single `plopfile.js` | Layered merge: defaults → presets → `forge.config.js` |
48
+ | **Generator IDs** | Flat names (`component`) | Namespaced (`rn-fsd:component`, `local:billing-service`) |
49
+ | **Multiple stacks** | One plopfile per project or manual wiring | Multiple presets in one config (`presets`, `presetMap`) |
50
+ | **Templates location** | Next to plopfile, often `plop-templates/` | Preset package (`@effijs/templates`) + `./forge/templates/` |
51
+ | **Template engines** | Handlebars (default) | Handlebars (default) or **Eta** (`templateEngine: "eta"`) |
52
+ | **Case helpers** | Add via `setHelper` / `helper-case` | Built-in (`camelCase`, `pascalCase`, `kebabCase`, …) |
53
+ | **CLI** | `plop <generator>` | `forge init`, `forge list`, `forge g <id>`, `forge new local` |
54
+ | **Local generator scaffold** | Manual | `forge new local <name> --type=service\|api\|component` |
55
+ | **Dry run / force** | Via API / custom | `--dry-run`, `--force`, `--yes` on CLI and `Forge.run()` |
56
+ | **Per-generator output** | Usually in generator actions | `overrides` in config (e.g. custom `outputDir`) |
57
+ | **Ambiguity** | Duplicate names = runtime confusion | Explicit error with suggestions when bare name matches several presets |
58
+
59
+ **When to use Forge:** monorepos or products that share generators across apps (React Native FSD + NestJS in one repo), versioned template packages, and EffiJS preset conventions.
60
+
61
+ **When Plop is enough:** a single app with a handful of local generators and no shared preset layer.
62
+
63
+ ### Generator shape (Plop-compatible)
64
+
65
+ ```js
66
+ export default {
67
+ description: "Optional description for forge list",
68
+ prompts: [/* inquirer questions */],
69
+ actions: [
70
+ { type: "add", path: "...", templateFile: "..." },
71
+ { type: "addMany", templateFiles: "feature", destination: "{{name}}" },
72
+ { type: "modify", path: "...", pattern: "...", template: "..." },
73
+ { type: "append", path: "...", pattern: "...", template: "..." },
74
+ { type: "custom", action: async (data, forge) => { /* ... */ } },
75
+ { type: "add", path: "...", template: "...", skip: (data) => !data.flag },
76
+ ],
77
+ };
78
+ ```
79
+
80
+ Generators are discovered as `**/generator.js` under each template root, or registered in a preset’s `generators` map.
81
+
82
+ ## Configuration (layered)
83
+
84
+ Config merges in order: **base defaults → extended presets → local project config**.
85
+
86
+ Supported config files (first match wins): `forge.config.js`, `forge.config.mjs`, `.forgerc.js`, `.forgerc.json`.
87
+
88
+ ```js
89
+ // forge.config.js
90
+ export default {
91
+ presets: ["rn-fsd", "nest"],
92
+ presetMap: {
93
+ "rn-fsd": "@effijs/templates/presets/react-native-fsd",
94
+ nest: "@effijs/templates/presets/nestjs-fsd",
95
+ },
96
+ primaryPreset: "rn-fsd",
97
+ outputDir: "./src",
98
+ templateEngine: "handlebars", // or "eta"
99
+ local: {
100
+ templatesDir: "./forge/templates",
101
+ generatorsDir: "./forge/generators",
102
+ },
103
+ generators: ["rn-fsd:component", "nest:controller", "local:billing-service"],
104
+ aliases: {
105
+ comp: "rn-fsd:component",
106
+ ctrl: "nest:controller",
107
+ },
108
+ overrides: {
109
+ "local:billing-service": { outputDir: "./src/billing" },
110
+ },
111
+ helpers: {
112
+ // custom Handlebars helpers when templateEngine is "handlebars"
113
+ },
114
+ };
115
+ ```
116
+
117
+ ### Generator resolution
118
+
119
+ For `forge g <input>`:
120
+
121
+ 1. Resolve `aliases[input]` (recursive)
122
+ 2. If input contains `:`, match catalog (`preset:action`)
123
+ 3. Bare `action` when exactly one preset defines it (e.g. `component` → `rn-fsd:component` if unambiguous)
124
+ 4. Fall back to `primaryPreset:action`
125
+ 5. Fall back to `local:action`
126
+ 6. On ambiguity, fail with a list of matching IDs
127
+
128
+ Examples:
129
+
130
+ ```bash
131
+ forge g comp # alias → rn-fsd:component
132
+ forge g rn-fsd:component # explicit namespace
133
+ forge g component # bare name if only one match (or primaryPreset)
134
+ forge g local:my-service # project-local generator
135
+ ```
136
+
137
+ ## CLI
138
+
139
+ | Command | Description |
140
+ | ------- | ----------- |
141
+ | `forge init` | Create `forge.config.js` and `./forge/` directories |
142
+ | `forge list` (`ls`) | List generators grouped by namespace + aliases |
143
+ | `forge g <id>` (`generate`) | Run a generator |
144
+ | `forge new local <name>` | Scaffold a local generator and templates |
145
+
146
+ ### Flags (`forge g`)
147
+
148
+ | Flag | Description |
149
+ | ---- | ----------- |
150
+ | `--dry-run` | Preview paths without writing files |
151
+ | `--force` | Overwrite existing files |
152
+ | `--yes` | Skip prompts and apply each prompt's `default`. Flags override defaults |
153
+ | `--name <name>` | Pass prompt value directly |
154
+ | `--path <path>` | Pass path prompt directly |
155
+ | `--<promptName> <value>` | Any other flag maps to prompt data (unknown options allowed) |
156
+
157
+ ```bash
158
+ forge g rn-fsd:component --yes --name Button
159
+ forge g feature --yes --name Note --screens list,detail --tab
160
+ forge g rn-fsd:component --dry-run --name Button --includeStyles=false
161
+ ```
162
+
163
+ ## Action types
164
+
165
+ | Type | Fields | Behavior |
166
+ | ---- | ------ | -------- |
167
+ | `add` | `path`, `templateFile` or `template` | Create a single file |
168
+ | `addMany` | `templateFiles`, `destination?`, `options?` | Render `.hbs` or `.eta` files; copy every other file as bytes |
169
+ | `modify` | `path`, `transform` or `pattern` + `template` | Transform existing file content |
170
+ | `append` | `path`, `template`, `pattern?`, `unique?`, `options?` | Insert at a marker. A missing marker fails. `unique` skips a fragment already in the file |
171
+ | `custom` | `action(data, forge)` | Arbitrary async logic |
172
+
173
+ All actions support `skip` or `when`: `(data) => boolean`.
174
+
175
+ Paths and templates are resolved relative to the generator’s preset root or `./forge/templates`. Template file names in `addMany` can contain Handlebars/Eta expressions when `processFileNames` is enabled.
176
+
177
+ ## Local generators
178
+
179
+ ```bash
180
+ forge new local billing-service --type=service
181
+ forge g local:billing-service
182
+ ```
183
+
184
+ Creates:
185
+
186
+ ```
187
+ forge/
188
+ generators/billing-service/generator.js
189
+ templates/billing-service/*.hbs
190
+ ```
191
+
192
+ Variants: `--type=service` | `api` | `component`. Use `--no-register` to skip auto-entry in `forge.config.js`, `--force` to overwrite.
193
+
194
+ > **Known limitation:** `forge list` discovers local generators, but `forge g local:<name>` currently fails to resolve them — `resolveGeneratorId` reads only the preset catalog, and the local template root contributes no catalog entries. Expose project generators through a small in-repo preset until this is fixed; see [Presets → local preset workaround](./docs/core/presets.md#local-preset-workaround).
195
+
196
+ ## Programmatic API
197
+
198
+ ```js
199
+ import { Forge } from "@effijs/forge";
200
+
201
+ const forge = await Forge.create(process.cwd());
202
+
203
+ const result = await forge.run("rn-fsd:component", {
204
+ yes: true,
205
+ name: "Button",
206
+ dryRun: false,
207
+ force: false,
208
+ });
209
+
210
+ if (result.success) {
211
+ console.log(result.generatorId, result.results);
212
+ }
213
+ ```
214
+
215
+ `Forge` also exposes `renderTemplate`, `addFile`, `modifyFile`, `processTemplate`, and `processDirectory` for custom actions.
216
+
217
+ ## Architecture
218
+
219
+ ```
220
+ src/
221
+ Forge.js # orchestration, prompts, run()
222
+ cli/ForgeCLI.js # commander CLI
223
+ cli/scaffoldLocal.js
224
+ config/ # load, merge, resolve generators & presets
225
+ engines/ # handlebars + eta
226
+ actions/ # action registry
227
+ fs/ # add, modify, process templates
228
+ utils/ # paths, skip, deepMerge
229
+ ```
230
+
231
+ ## Related packages
232
+
233
+ - [`@effijs/templates`](../templates) — extended presets (`react-native-fsd`, …) and shared generators
234
+ - Example generator: `generators/shared/component` (component with optional styles, tests, barrel append)
package/index.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from "url";
3
+ import { ForgeCLI } from "./src/index.js";
4
+
5
+ const cli = new ForgeCLI();
6
+ await cli.init();
7
+ cli.run(process.argv);
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@effijs/forge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-alpha.1",
4
+ "type": "module",
5
+ "description": "Modern scaffolding engine for EffiJS",
6
+ "author": "Yevhenii Kyrychok",
7
+ "license": "MIT",
8
+ "main": "src/index.js",
9
+ "bin": {
10
+ "forge": "./index.js"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.js"
14
+ },
15
+ "keywords": [
16
+ "forge",
17
+ "scaffolding",
18
+ "generator",
19
+ "effijs",
20
+ "cli"
21
+ ],
22
+ "files": [
23
+ "index.js",
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "forge": "node index.js",
30
+ "test": "node --test test/*.test.js",
31
+ "docs:dev": "vitepress dev docs",
32
+ "docs:build": "vitepress build docs",
33
+ "docs:preview": "vitepress preview docs"
34
+ },
35
+ "dependencies": {
36
+ "chalk": "^5.6.2",
37
+ "change-case": "^5.4.4",
38
+ "commander": "^11.1.0",
39
+ "eta": "^3.5.0",
40
+ "handlebars": "^4.7.10",
41
+ "inquirer": "^9.3.8",
42
+ "ora": "^8.2.0",
43
+ "tinyglobby": "^0.2.17"
44
+ },
45
+ "devDependencies": {
46
+ "vitepress": "^1.6.4"
47
+ },
48
+ "engines": {
49
+ "node": ">=20.6.0"
50
+ }
51
+ }
package/src/Forge.js ADDED
@@ -0,0 +1,187 @@
1
+ import path from "path";
2
+ import inquirer from "inquirer";
3
+ import chalk from "chalk";
4
+ import { compileHandlebars, registerHandlebarsHelpers } from "./engines/handlebars.js";
5
+ import { createEtaEngine, renderEtaString } from "./engines/eta.js";
6
+ import { mergeConfig } from "./config/mergeConfig.js";
7
+ import {
8
+ resolveGeneratorId,
9
+ loadGenerator,
10
+ } from "./config/resolveGenerator.js";
11
+ import { runActions } from "./actions/index.js";
12
+ import { addFile } from "./fs/addFile.js";
13
+ import { modifyFile, appendToFile } from "./fs/modifyFile.js";
14
+ import { processTemplate, processDirectory } from "./fs/processTemplate.js";
15
+
16
+ async function applyPromptDefaults(questions, options) {
17
+ const answers = {};
18
+ const list = Array.isArray(questions) ? questions : [];
19
+
20
+ for (const question of list) {
21
+ if (!question?.name) continue;
22
+ if (options[question.name] !== undefined) continue;
23
+
24
+ const context = { ...answers, ...options };
25
+ let active = question.when;
26
+ if (typeof active === "function") active = await active(context);
27
+ if (active === false) continue;
28
+ if (question.default === undefined) continue;
29
+
30
+ answers[question.name] =
31
+ typeof question.default === "function"
32
+ ? await question.default(context)
33
+ : question.default;
34
+ }
35
+
36
+ return answers;
37
+ }
38
+
39
+ export class Forge {
40
+ constructor(config = {}) {
41
+ this.config = config;
42
+ this.paths = config.paths ?? {};
43
+ this.generators = new Map();
44
+
45
+ if (this.config.templateEngine === "eta") {
46
+ this.eta = createEtaEngine(this.paths.templatesDir);
47
+ } else {
48
+ registerHandlebarsHelpers(this.config.helpers ?? {});
49
+ }
50
+ }
51
+
52
+ static async create(cwd = process.cwd()) {
53
+ const config = await mergeConfig(cwd);
54
+ return new Forge(config);
55
+ }
56
+
57
+ async prompt(questions) {
58
+ return inquirer.prompt(questions);
59
+ }
60
+
61
+ async renderTemplate(templateContent, data) {
62
+ if (this.config.templateEngine === "eta") {
63
+ return renderEtaString(this.eta, templateContent, data);
64
+ }
65
+ return compileHandlebars(templateContent, data);
66
+ }
67
+
68
+ async addFile(filePath, content, options = {}) {
69
+ return addFile(filePath, content, {
70
+ dryRun: options.dryRun ?? this.runOptions?.dryRun,
71
+ force: options.force ?? this.runOptions?.force,
72
+ });
73
+ }
74
+
75
+ async modifyFile(filePath, modifier, options = {}) {
76
+ return modifyFile(filePath, modifier, {
77
+ dryRun: options.dryRun ?? this.runOptions?.dryRun,
78
+ force: options.force ?? this.runOptions?.force,
79
+ });
80
+ }
81
+
82
+ async appendToFile(filePath, content, appendOptions = {}, runOptions = {}) {
83
+ return appendToFile(filePath, content, appendOptions, {
84
+ dryRun: runOptions.dryRun ?? this.runOptions?.dryRun,
85
+ force: runOptions.force ?? this.runOptions?.force,
86
+ });
87
+ }
88
+
89
+ async processTemplate(templatePath, outputPath, data) {
90
+ return processTemplate(
91
+ templatePath,
92
+ outputPath,
93
+ (content, d) => this.renderTemplate(content, d),
94
+ {
95
+ data,
96
+ dryRun: this.runOptions?.dryRun,
97
+ force: this.runOptions?.force,
98
+ },
99
+ );
100
+ }
101
+
102
+ async processDirectory(templateDir, outputDir, data, options = {}) {
103
+ return processDirectory(
104
+ templateDir,
105
+ outputDir,
106
+ (content, d) => this.renderTemplate(content, d),
107
+ data,
108
+ {
109
+ ...options,
110
+ templateSuffix:
111
+ options.templateSuffix ??
112
+ (this.config.templateEngine === "eta" ? ".eta" : ".hbs"),
113
+ dryRun: this.runOptions?.dryRun,
114
+ force: this.runOptions?.force,
115
+ },
116
+ );
117
+ }
118
+
119
+ resolveOutputDir(generatorId) {
120
+ const override = this.config.overrides?.[generatorId];
121
+ if (override?.outputDir) {
122
+ const root = this.paths.root;
123
+ return path.isAbsolute(override.outputDir)
124
+ ? override.outputDir
125
+ : path.resolve(root, override.outputDir);
126
+ }
127
+ return this.paths.outputDir;
128
+ }
129
+
130
+ async run(generatorInput, options = {}) {
131
+ this.runOptions = options;
132
+ const { dryRun = false, force = false, yes = false } = options;
133
+
134
+ const generatorId = resolveGeneratorId(generatorInput, this.config);
135
+ console.log(
136
+ chalk.bold.cyan(`\n🔨 Running ${generatorId} generator\n`),
137
+ );
138
+
139
+ try {
140
+ const generator = await loadGenerator(generatorId, this.config);
141
+
142
+ let answers = {};
143
+ if (generator.prompts) {
144
+ const questions =
145
+ typeof generator.prompts === "function"
146
+ ? await generator.prompts(this)
147
+ : generator.prompts;
148
+ answers = yes
149
+ ? await applyPromptDefaults(questions, options)
150
+ : await this.prompt(questions);
151
+ }
152
+
153
+ const data = { ...answers, ...options };
154
+ const outputDir = this.resolveOutputDir(generatorId);
155
+
156
+ const ctx = {
157
+ forge: this,
158
+ generator,
159
+ data,
160
+ outputDir,
161
+ dryRun,
162
+ force,
163
+ abortOnError: options.abortOnError ?? true,
164
+ };
165
+
166
+ const { results, hasFailure } = await runActions(generator, ctx);
167
+
168
+ if (hasFailure) {
169
+ console.error(
170
+ chalk.red("\n❌ Generation failed — one or more actions failed\n"),
171
+ );
172
+ return { success: false, results };
173
+ }
174
+
175
+ if (dryRun) {
176
+ console.log(chalk.bold.yellow("\n✨ Dry run complete (no files written)\n"));
177
+ } else {
178
+ console.log(chalk.bold.green("\n✨ Generation complete!\n"));
179
+ }
180
+
181
+ return { success: true, results, generatorId };
182
+ } catch (error) {
183
+ console.error(chalk.red(`\n❌ Generation failed: ${error.message}\n`));
184
+ return { success: false, error: error.message };
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,175 @@
1
+ import path from "path";
2
+ import { shouldSkip } from "../utils/shouldSkip.js";
3
+ import { processTemplate, processDirectory } from "../fs/processTemplate.js";
4
+ import { addFile } from "../fs/addFile.js";
5
+ import { modifyFile, appendToFile } from "../fs/modifyFile.js";
6
+
7
+ async function resolveTemplatePath(ctx, templateFile) {
8
+ const root = ctx.generator._root;
9
+ const templatesDir = root?.templatesDir ?? ctx.forge.paths.templatesDir;
10
+
11
+ if (path.isAbsolute(templateFile)) return templateFile;
12
+
13
+ const candidates = [
14
+ path.join(templatesDir, templateFile),
15
+ path.join(path.dirname(ctx.generator._generatorPath), templateFile),
16
+ path.join(templatesDir, path.basename(templateFile)),
17
+ ];
18
+
19
+ const fs = await import("fs/promises");
20
+ for (const candidate of candidates) {
21
+ try {
22
+ await fs.access(candidate);
23
+ return candidate;
24
+ } catch {
25
+ // try next
26
+ }
27
+ }
28
+
29
+ return candidates[0];
30
+ }
31
+
32
+ async function runAddAction(action, ctx) {
33
+ const outputPath = path.join(
34
+ ctx.outputDir,
35
+ await ctx.forge.renderTemplate(action.path, ctx.data),
36
+ );
37
+
38
+ if (action.templateFile) {
39
+ const templatePath = await resolveTemplatePath(ctx, action.templateFile);
40
+ return processTemplate(
41
+ templatePath,
42
+ outputPath,
43
+ (content, data) => ctx.forge.renderTemplate(content, data),
44
+ { data: ctx.data, dryRun: ctx.dryRun, force: ctx.force },
45
+ );
46
+ }
47
+
48
+ if (action.template) {
49
+ const content = await ctx.forge.renderTemplate(action.template, ctx.data);
50
+ return addFile(outputPath, content, {
51
+ dryRun: ctx.dryRun,
52
+ force: ctx.force,
53
+ });
54
+ }
55
+ }
56
+
57
+ async function runAddManyAction(action, ctx) {
58
+ const root = ctx.generator._root;
59
+ const templatesDir = root?.templatesDir ?? ctx.forge.paths.templatesDir;
60
+ const templateDir = path.join(templatesDir, action.templateFiles);
61
+ const outputDir = path.join(
62
+ ctx.outputDir,
63
+ action.destination
64
+ ? await ctx.forge.renderTemplate(action.destination, ctx.data)
65
+ : "",
66
+ );
67
+
68
+ const templateSuffix =
69
+ ctx.forge.config.templateEngine === "eta" ? ".eta" : ".hbs";
70
+
71
+ const results = await processDirectory(
72
+ templateDir,
73
+ outputDir,
74
+ (content, data) => ctx.forge.renderTemplate(content, data),
75
+ ctx.data,
76
+ {
77
+ ...action.options,
78
+ dryRun: ctx.dryRun,
79
+ force: ctx.force,
80
+ templateSuffix,
81
+ },
82
+ );
83
+
84
+ const failed = results.filter((r) => r && !r.success);
85
+ if (failed.length) {
86
+ return { success: false, results, error: `${failed.length} file(s) failed` };
87
+ }
88
+
89
+ return { success: true, results };
90
+ }
91
+
92
+ async function runModifyAction(action, ctx) {
93
+ const targetPath = path.join(
94
+ ctx.outputDir,
95
+ await ctx.forge.renderTemplate(action.path, ctx.data),
96
+ );
97
+
98
+ return modifyFile(
99
+ targetPath,
100
+ async (content) => {
101
+ if (action.transform) {
102
+ return action.transform(content, ctx.data);
103
+ }
104
+ if (action.pattern && action.template) {
105
+ const rendered = await ctx.forge.renderTemplate(
106
+ action.template,
107
+ ctx.data,
108
+ );
109
+ return content.replace(
110
+ new RegExp(action.pattern, action.flags || "g"),
111
+ rendered,
112
+ );
113
+ }
114
+ return content;
115
+ },
116
+ { dryRun: ctx.dryRun, force: ctx.force },
117
+ );
118
+ }
119
+
120
+ async function runAppendAction(action, ctx) {
121
+ const appendPath = path.join(
122
+ ctx.outputDir,
123
+ await ctx.forge.renderTemplate(action.path, ctx.data),
124
+ );
125
+ const appendContent = await ctx.forge.renderTemplate(
126
+ action.template || "",
127
+ ctx.data,
128
+ );
129
+
130
+ return appendToFile(appendPath, appendContent, action, {
131
+ dryRun: ctx.dryRun,
132
+ force: ctx.force,
133
+ });
134
+ }
135
+
136
+ async function runCustomAction(action, ctx) {
137
+ if (action.action) {
138
+ return action.action(ctx.data, ctx.forge);
139
+ }
140
+ }
141
+
142
+ export const actionHandlers = new Map([
143
+ ["add", runAddAction],
144
+ ["addMany", runAddManyAction],
145
+ ["modify", runModifyAction],
146
+ ["append", runAppendAction],
147
+ ["custom", runCustomAction],
148
+ ]);
149
+
150
+ export async function runActions(generator, ctx) {
151
+ const results = [];
152
+ let hasFailure = false;
153
+
154
+ for (const action of generator.actions ?? []) {
155
+ if (await shouldSkip(action, ctx.data)) {
156
+ continue;
157
+ }
158
+
159
+ const handler = actionHandlers.get(action.type);
160
+ if (!handler) {
161
+ throw new Error(`Unknown action type: ${action.type}`);
162
+ }
163
+
164
+ const result = await handler(action, ctx);
165
+ if (result) {
166
+ results.push(result);
167
+ if (result.success === false) {
168
+ hasFailure = true;
169
+ if (ctx.abortOnError !== false) break;
170
+ }
171
+ }
172
+ }
173
+
174
+ return { results, hasFailure };
175
+ }