@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 +22 -0
- package/README.md +233 -2
- package/index.js +7 -0
- package/package.json +49 -4
- package/src/Forge.js +187 -0
- package/src/actions/index.js +175 -0
- package/src/cli/ForgeCLI.js +189 -0
- package/src/cli/parseArgv.js +50 -0
- package/src/cli/scaffoldLocal.js +228 -0
- package/src/config/esmConfigLoader.js +37 -0
- package/src/config/loadConfig.js +62 -0
- package/src/config/loadPreset.js +85 -0
- package/src/config/mergeConfig.js +104 -0
- package/src/config/resolveGenerator.js +149 -0
- package/src/engines/eta.js +28 -0
- package/src/engines/handlebars.js +61 -0
- package/src/fs/addFile.js +34 -0
- package/src/fs/modifyFile.js +110 -0
- package/src/fs/processTemplate.js +133 -0
- package/src/index.js +3 -0
- package/src/utils/deepMerge.js +23 -0
- package/src/utils/resolvePaths.js +27 -0
- package/src/utils/shouldSkip.js +13 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { pathToFileURL } from "url";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import { glob } from "tinyglobby";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve user input to a namespaced generator id.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveGeneratorId(input, config) {
|
|
10
|
+
const aliases = config.aliases ?? {};
|
|
11
|
+
const catalog = config.generatorCatalog ?? new Map();
|
|
12
|
+
const whitelist = config.generators;
|
|
13
|
+
|
|
14
|
+
if (aliases[input]) {
|
|
15
|
+
return resolveGeneratorId(aliases[input], config);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (input.includes(":")) {
|
|
19
|
+
if (aliases[input]) return aliases[input];
|
|
20
|
+
|
|
21
|
+
if (catalog.has(input)) return input;
|
|
22
|
+
|
|
23
|
+
const [namespace, action] = input.split(":");
|
|
24
|
+
const namespaced = `${namespace}:${action}`;
|
|
25
|
+
if (catalog.has(namespaced)) return namespaced;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const bareMatches = [...catalog.keys()].filter(
|
|
29
|
+
(id) => id.endsWith(`:${input}`) || id === `local:${input}`,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
if (bareMatches.length === 1) {
|
|
33
|
+
const resolved = bareMatches[0];
|
|
34
|
+
if (!whitelist?.length || whitelist.includes(resolved)) {
|
|
35
|
+
return resolved;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (bareMatches.length > 1) {
|
|
40
|
+
const suggestions = bareMatches.map((id) => ` ${id}`).join("\n");
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Ambiguous generator "${input}". Did you mean:\n${suggestions}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const primary = config.primaryPreset;
|
|
47
|
+
if (primary) {
|
|
48
|
+
const primaryId = `${primary}:${input}`;
|
|
49
|
+
if (catalog.has(primaryId)) return primaryId;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (catalog.has(`local:${input}`)) return `local:${input}`;
|
|
53
|
+
|
|
54
|
+
throw new Error(`Generator "${input}" not found`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find generator.js path for a resolved generator id.
|
|
59
|
+
*/
|
|
60
|
+
export async function findGeneratorPath(generatorId, config) {
|
|
61
|
+
const [namespace, action] = generatorId.split(":");
|
|
62
|
+
const root = config.templateRoots?.find((r) => r.namespace === namespace);
|
|
63
|
+
|
|
64
|
+
if (!root) {
|
|
65
|
+
throw new Error(`No template root for namespace "${namespace}"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const meta = config.generatorCatalog?.get(generatorId);
|
|
69
|
+
const subPath = meta?.path ?? action;
|
|
70
|
+
|
|
71
|
+
const candidates = [
|
|
72
|
+
path.join(root.generatorsDir, subPath, "generator.js"),
|
|
73
|
+
path.join(root.generatorsDir, action, "generator.js"),
|
|
74
|
+
path.join(root.templatesDir, subPath, "generator.js"),
|
|
75
|
+
path.join(root.templatesDir, action, "generator.js"),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
for (const candidate of candidates) {
|
|
79
|
+
try {
|
|
80
|
+
await fs.access(candidate);
|
|
81
|
+
return { generatorPath: candidate, root, meta };
|
|
82
|
+
} catch {
|
|
83
|
+
// continue
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Generator file not found for "${generatorId}" in ${root.generatorsDir}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Load generator module by id.
|
|
94
|
+
*/
|
|
95
|
+
export async function loadGenerator(generatorId, config) {
|
|
96
|
+
const { generatorPath, root, meta } = await findGeneratorPath(
|
|
97
|
+
generatorId,
|
|
98
|
+
config,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const module = await import(
|
|
103
|
+
pathToFileURL(generatorPath).href + `?t=${Date.now()}`
|
|
104
|
+
);
|
|
105
|
+
const generator = module.default ?? module;
|
|
106
|
+
return {
|
|
107
|
+
...generator,
|
|
108
|
+
id: generatorId,
|
|
109
|
+
_root: root,
|
|
110
|
+
_meta: meta,
|
|
111
|
+
_generatorPath: generatorPath,
|
|
112
|
+
};
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Failed to load generator "${generatorId}": ${error.message}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* List all generators from template roots (filesystem scan fallback).
|
|
122
|
+
*/
|
|
123
|
+
export async function discoverGenerators(config) {
|
|
124
|
+
const discovered = new Map(config.generatorCatalog ?? []);
|
|
125
|
+
|
|
126
|
+
for (const root of config.templateRoots ?? []) {
|
|
127
|
+
const files = await glob("**/generator.js", {
|
|
128
|
+
cwd: root.generatorsDir,
|
|
129
|
+
onlyFiles: true,
|
|
130
|
+
}).catch(() => []);
|
|
131
|
+
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
const dir = path.dirname(file);
|
|
134
|
+
const name = path.basename(dir);
|
|
135
|
+
const id = `${root.namespace}:${name}`;
|
|
136
|
+
if (!discovered.has(id)) {
|
|
137
|
+
discovered.set(id, {
|
|
138
|
+
id,
|
|
139
|
+
name,
|
|
140
|
+
namespace: root.namespace,
|
|
141
|
+
path: dir,
|
|
142
|
+
root,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return discovered;
|
|
149
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Eta } from "eta";
|
|
2
|
+
import * as changeCase from "change-case";
|
|
3
|
+
|
|
4
|
+
const CASE_FUNCTIONS = {
|
|
5
|
+
camelCase: changeCase.camelCase,
|
|
6
|
+
capitalCase: changeCase.capitalCase,
|
|
7
|
+
constantCase: changeCase.constantCase,
|
|
8
|
+
dotCase: changeCase.dotCase,
|
|
9
|
+
kebabCase: changeCase.kebabCase,
|
|
10
|
+
noCase: changeCase.noCase,
|
|
11
|
+
pascalCase: changeCase.pascalCase,
|
|
12
|
+
pathCase: changeCase.pathCase,
|
|
13
|
+
sentenceCase: changeCase.sentenceCase,
|
|
14
|
+
snakeCase: changeCase.snakeCase,
|
|
15
|
+
trainCase: changeCase.trainCase,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function createEtaEngine(templatesDir) {
|
|
19
|
+
return new Eta({
|
|
20
|
+
views: templatesDir,
|
|
21
|
+
autoEscape: false,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderEtaString(eta, templateContent, data) {
|
|
26
|
+
const context = { ...data, ...CASE_FUNCTIONS };
|
|
27
|
+
return eta.renderString(templateContent, context);
|
|
28
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import Handlebars from "handlebars";
|
|
2
|
+
import * as changeCase from "change-case";
|
|
3
|
+
|
|
4
|
+
const CASE_FUNCTIONS = {
|
|
5
|
+
camelCase: changeCase.camelCase,
|
|
6
|
+
capitalCase: changeCase.capitalCase,
|
|
7
|
+
constantCase: changeCase.constantCase,
|
|
8
|
+
dotCase: changeCase.dotCase,
|
|
9
|
+
kebabCase: changeCase.kebabCase,
|
|
10
|
+
noCase: changeCase.noCase,
|
|
11
|
+
pascalCase: changeCase.pascalCase,
|
|
12
|
+
pathCase: changeCase.pathCase,
|
|
13
|
+
sentenceCase: changeCase.sentenceCase,
|
|
14
|
+
snakeCase: changeCase.snakeCase,
|
|
15
|
+
trainCase: changeCase.trainCase,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let helpersRegistered = false;
|
|
19
|
+
|
|
20
|
+
export function registerHandlebarsHelpers(customHelpers = {}) {
|
|
21
|
+
if (helpersRegistered) return;
|
|
22
|
+
|
|
23
|
+
Object.entries(CASE_FUNCTIONS).forEach(([name, fn]) => {
|
|
24
|
+
Handlebars.registerHelper(name, fn);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
Handlebars.registerHelper("eq", (a, b) => a === b);
|
|
28
|
+
Handlebars.registerHelper("ne", (a, b) => a !== b);
|
|
29
|
+
Handlebars.registerHelper("lt", (a, b) => a < b);
|
|
30
|
+
Handlebars.registerHelper("gt", (a, b) => a > b);
|
|
31
|
+
Handlebars.registerHelper("lte", (a, b) => a <= b);
|
|
32
|
+
Handlebars.registerHelper("gte", (a, b) => a >= b);
|
|
33
|
+
Handlebars.registerHelper("and", (...args) =>
|
|
34
|
+
args.slice(0, -1).every(Boolean),
|
|
35
|
+
);
|
|
36
|
+
Handlebars.registerHelper("or", (...args) =>
|
|
37
|
+
args.slice(0, -1).some(Boolean),
|
|
38
|
+
);
|
|
39
|
+
Handlebars.registerHelper("not", (value) => !value);
|
|
40
|
+
Handlebars.registerHelper("includes", (arr, value) => {
|
|
41
|
+
if (!arr) return false;
|
|
42
|
+
if (Array.isArray(arr)) return arr.includes(value);
|
|
43
|
+
return String(arr).includes(String(value));
|
|
44
|
+
});
|
|
45
|
+
Handlebars.registerHelper("concat", (...args) =>
|
|
46
|
+
args.slice(0, -1).join(""),
|
|
47
|
+
);
|
|
48
|
+
Handlebars.registerHelper("json", (value) => JSON.stringify(value));
|
|
49
|
+
|
|
50
|
+
Object.entries(customHelpers).forEach(([name, fn]) => {
|
|
51
|
+
Handlebars.registerHelper(name, fn);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
helpersRegistered = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function compileHandlebars(templateContent, data) {
|
|
58
|
+
registerHandlebarsHelpers();
|
|
59
|
+
const template = Handlebars.compile(templateContent);
|
|
60
|
+
return template(data);
|
|
61
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
|
|
6
|
+
export async function addFile(filePath, content, options = {}) {
|
|
7
|
+
const { dryRun = false, force = false } = options;
|
|
8
|
+
const spinner = ora(`Creating ${chalk.blue(filePath)}`).start();
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const exists = await fs
|
|
12
|
+
.access(filePath)
|
|
13
|
+
.then(() => true)
|
|
14
|
+
.catch(() => false);
|
|
15
|
+
|
|
16
|
+
if (exists && !force) {
|
|
17
|
+
spinner.warn(`Skipped (exists) ${chalk.yellow(filePath)}`);
|
|
18
|
+
return { success: true, skipped: true, path: filePath };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (dryRun) {
|
|
22
|
+
spinner.info(`[dry-run] Would create ${chalk.cyan(filePath)}`);
|
|
23
|
+
return { success: true, dryRun: true, path: filePath };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
27
|
+
await fs.writeFile(filePath, content);
|
|
28
|
+
spinner.succeed(`Created ${chalk.green(filePath)}`);
|
|
29
|
+
return { success: true, path: filePath };
|
|
30
|
+
} catch (error) {
|
|
31
|
+
spinner.fail(`Failed to create ${filePath}: ${error.message}`);
|
|
32
|
+
return { success: false, error: error.message };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
|
|
5
|
+
const FILE_CONTROL = Symbol("forgeFileControl");
|
|
6
|
+
|
|
7
|
+
function skipControl() {
|
|
8
|
+
return { [FILE_CONTROL]: "skip" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function failControl(error) {
|
|
12
|
+
return { [FILE_CONTROL]: "fail", error };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveAppendOptions(input = {}) {
|
|
16
|
+
const nested =
|
|
17
|
+
input.options && typeof input.options === "object" ? input.options : {};
|
|
18
|
+
const pick = (key) => (input[key] !== undefined ? input[key] : nested[key]);
|
|
19
|
+
return {
|
|
20
|
+
pattern: pick("pattern"),
|
|
21
|
+
flags: pick("flags"),
|
|
22
|
+
before: pick("before") ?? false,
|
|
23
|
+
unique: Boolean(pick("unique")),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function compileAppendPattern(pattern, flags) {
|
|
28
|
+
if (pattern instanceof RegExp) {
|
|
29
|
+
return new RegExp(pattern.source, pattern.flags);
|
|
30
|
+
}
|
|
31
|
+
return new RegExp(pattern, flags ?? "g");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function patternLabel(pattern) {
|
|
35
|
+
return pattern instanceof RegExp ? pattern.toString() : String(pattern);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function modifyFile(filePath, modifier, options = {}) {
|
|
39
|
+
const { dryRun = false, force = false } = options;
|
|
40
|
+
const spinner = ora(`Modifying ${chalk.blue(filePath)}`).start();
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const exists = await fs
|
|
44
|
+
.access(filePath)
|
|
45
|
+
.then(() => true)
|
|
46
|
+
.catch(() => false);
|
|
47
|
+
|
|
48
|
+
if (!exists) {
|
|
49
|
+
if (!force) {
|
|
50
|
+
spinner.fail(`File not found: ${filePath}`);
|
|
51
|
+
return { success: false, error: "File not found" };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const content = exists ? await fs.readFile(filePath, "utf-8") : "";
|
|
56
|
+
const modified = await modifier(content);
|
|
57
|
+
|
|
58
|
+
if (modified && typeof modified === "object" && modified[FILE_CONTROL]) {
|
|
59
|
+
if (modified[FILE_CONTROL] === "skip") {
|
|
60
|
+
spinner.warn(`Skipped (already present) ${chalk.yellow(filePath)}`);
|
|
61
|
+
return { success: true, skipped: true, path: filePath };
|
|
62
|
+
}
|
|
63
|
+
const error = modified.error || "Failed to modify file";
|
|
64
|
+
spinner.fail(error);
|
|
65
|
+
return { success: false, error, path: filePath };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (dryRun) {
|
|
69
|
+
spinner.info(`[dry-run] Would modify ${chalk.cyan(filePath)}`);
|
|
70
|
+
return { success: true, dryRun: true, path: filePath };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await fs.writeFile(filePath, modified);
|
|
74
|
+
spinner.succeed(`Modified ${chalk.green(filePath)}`);
|
|
75
|
+
return { success: true, path: filePath };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
spinner.fail(`Failed to modify ${filePath}: ${error.message}`);
|
|
78
|
+
return { success: false, error: error.message };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function appendToFile(filePath, content, appendOptions = {}, runOptions = {}) {
|
|
83
|
+
const { pattern, flags, before, unique } = resolveAppendOptions(appendOptions);
|
|
84
|
+
|
|
85
|
+
return modifyFile(
|
|
86
|
+
filePath,
|
|
87
|
+
(fileContent) => {
|
|
88
|
+
const fragment = String(content).trim();
|
|
89
|
+
if (unique && fragment && fileContent.includes(fragment)) {
|
|
90
|
+
return skipControl();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (pattern === undefined || pattern === null || pattern === "") {
|
|
94
|
+
return fileContent + content;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const regex = compileAppendPattern(pattern, flags);
|
|
98
|
+
const matched = regex.test(fileContent);
|
|
99
|
+
regex.lastIndex = 0;
|
|
100
|
+
if (!matched) {
|
|
101
|
+
return failControl(`Marker not found: ${patternLabel(pattern)}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return fileContent.replace(regex, (match) =>
|
|
105
|
+
before ? content + match : match + content,
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
runOptions,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import { glob } from "tinyglobby";
|
|
6
|
+
|
|
7
|
+
export async function processTemplate(
|
|
8
|
+
templatePath,
|
|
9
|
+
outputPath,
|
|
10
|
+
renderTemplate,
|
|
11
|
+
options = {},
|
|
12
|
+
) {
|
|
13
|
+
const { dryRun = false, force = false } = options;
|
|
14
|
+
const spinner = ora(
|
|
15
|
+
`Processing ${chalk.blue(path.basename(templatePath))}`,
|
|
16
|
+
).start();
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const templateContent = await fs.readFile(templatePath, "utf-8");
|
|
20
|
+
const rendered = await renderTemplate(templateContent, options.data);
|
|
21
|
+
|
|
22
|
+
const exists = await fs
|
|
23
|
+
.access(outputPath)
|
|
24
|
+
.then(() => true)
|
|
25
|
+
.catch(() => false);
|
|
26
|
+
|
|
27
|
+
if (exists && !force) {
|
|
28
|
+
spinner.warn(`Skipped (exists) ${chalk.yellow(outputPath)}`);
|
|
29
|
+
return { success: true, skipped: true, path: outputPath };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (dryRun) {
|
|
33
|
+
spinner.info(`[dry-run] Would create ${chalk.cyan(outputPath)}`);
|
|
34
|
+
return { success: true, dryRun: true, path: outputPath };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
38
|
+
await fs.writeFile(outputPath, rendered);
|
|
39
|
+
spinner.succeed(`Created ${chalk.green(outputPath)}`);
|
|
40
|
+
return { success: true, path: outputPath };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
spinner.fail(`Failed to process ${templatePath}: ${error.message}`);
|
|
43
|
+
return { success: false, error: error.message };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function copyStaticFile(sourcePath, outputPath, options = {}) {
|
|
48
|
+
const { dryRun = false, force = false } = options;
|
|
49
|
+
const spinner = ora(`Copying ${chalk.blue(path.basename(sourcePath))}`).start();
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const exists = await fs
|
|
53
|
+
.access(outputPath)
|
|
54
|
+
.then(() => true)
|
|
55
|
+
.catch(() => false);
|
|
56
|
+
|
|
57
|
+
if (exists && !force) {
|
|
58
|
+
spinner.warn(`Skipped (exists) ${chalk.yellow(outputPath)}`);
|
|
59
|
+
return { success: true, skipped: true, path: outputPath };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (dryRun) {
|
|
63
|
+
spinner.info(`[dry-run] Would create ${chalk.cyan(outputPath)}`);
|
|
64
|
+
return { success: true, dryRun: true, path: outputPath };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
68
|
+
await fs.copyFile(sourcePath, outputPath);
|
|
69
|
+
spinner.succeed(`Created ${chalk.green(outputPath)}`);
|
|
70
|
+
return { success: true, path: outputPath };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
spinner.fail(`Failed to copy ${sourcePath}: ${error.message}`);
|
|
73
|
+
return { success: false, error: error.message };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function processDirectory(
|
|
78
|
+
templateDir,
|
|
79
|
+
outputDir,
|
|
80
|
+
renderTemplate,
|
|
81
|
+
data,
|
|
82
|
+
options = {},
|
|
83
|
+
) {
|
|
84
|
+
const {
|
|
85
|
+
dryRun = false,
|
|
86
|
+
force = false,
|
|
87
|
+
ignore,
|
|
88
|
+
processFileNames = true,
|
|
89
|
+
templateSuffix = ".hbs",
|
|
90
|
+
} = options;
|
|
91
|
+
|
|
92
|
+
const spinner = ora("Scanning templates...").start();
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const templateFiles = await glob("**/*", {
|
|
96
|
+
cwd: templateDir,
|
|
97
|
+
onlyFiles: true,
|
|
98
|
+
ignore: ignore || ["**/node_modules/**", "**/.git/**", "**/generator.js"],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
spinner.succeed(`Found ${templateFiles.length} template files`);
|
|
102
|
+
|
|
103
|
+
const results = [];
|
|
104
|
+
for (const file of templateFiles) {
|
|
105
|
+
const templatePath = path.join(templateDir, file);
|
|
106
|
+
|
|
107
|
+
let outputFileName = file;
|
|
108
|
+
if (processFileNames !== false) {
|
|
109
|
+
outputFileName = await renderTemplate(file, data);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isTemplate = file.endsWith(templateSuffix);
|
|
113
|
+
if (outputFileName.endsWith(templateSuffix)) {
|
|
114
|
+
outputFileName = outputFileName.slice(0, -templateSuffix.length);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const outputPath = path.join(outputDir, outputFileName);
|
|
118
|
+
const result = isTemplate
|
|
119
|
+
? await processTemplate(templatePath, outputPath, renderTemplate, {
|
|
120
|
+
data,
|
|
121
|
+
dryRun,
|
|
122
|
+
force,
|
|
123
|
+
})
|
|
124
|
+
: await copyStaticFile(templatePath, outputPath, { dryRun, force });
|
|
125
|
+
results.push(result);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return results;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
spinner.fail(`Failed to process directory: ${error.message}`);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep-merge plain objects (arrays replaced, not concatenated).
|
|
3
|
+
*/
|
|
4
|
+
export function deepMerge(target, source) {
|
|
5
|
+
const result = { ...target };
|
|
6
|
+
|
|
7
|
+
for (const [key, value] of Object.entries(source ?? {})) {
|
|
8
|
+
if (
|
|
9
|
+
value &&
|
|
10
|
+
typeof value === "object" &&
|
|
11
|
+
!Array.isArray(value) &&
|
|
12
|
+
typeof result[key] === "object" &&
|
|
13
|
+
result[key] &&
|
|
14
|
+
!Array.isArray(result[key])
|
|
15
|
+
) {
|
|
16
|
+
result[key] = deepMerge(result[key], value);
|
|
17
|
+
} else if (value !== undefined) {
|
|
18
|
+
result[key] = value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve forge paths from config relative to project root.
|
|
5
|
+
* @param {object} config
|
|
6
|
+
* @param {string} [configFileDir]
|
|
7
|
+
*/
|
|
8
|
+
export function resolveForgePaths(config, configFileDir) {
|
|
9
|
+
const root = config.root ?? configFileDir ?? process.cwd();
|
|
10
|
+
|
|
11
|
+
const resolve = (dir) => {
|
|
12
|
+
if (!dir) return root;
|
|
13
|
+
return path.isAbsolute(dir) ? dir : path.resolve(root, dir);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
root,
|
|
18
|
+
templatesDir: resolve(config.templatesDir ?? "./templates"),
|
|
19
|
+
outputDir: resolve(config.outputDir ?? "./src"),
|
|
20
|
+
localTemplatesDir: config.local?.templatesDir
|
|
21
|
+
? resolve(config.local.templatesDir)
|
|
22
|
+
: resolve("./forge/templates"),
|
|
23
|
+
localGeneratorsDir: config.local?.generatorsDir
|
|
24
|
+
? resolve(config.local.generatorsDir)
|
|
25
|
+
: resolve("./forge/generators"),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluate action.skip / action.when predicate.
|
|
3
|
+
* @param {object} action
|
|
4
|
+
* @param {object} data
|
|
5
|
+
*/
|
|
6
|
+
export async function shouldSkip(action, data) {
|
|
7
|
+
const predicate = action.skip ?? action.when;
|
|
8
|
+
if (!predicate) return false;
|
|
9
|
+
if (typeof predicate === "function") {
|
|
10
|
+
return Boolean(await predicate(data));
|
|
11
|
+
}
|
|
12
|
+
return Boolean(predicate);
|
|
13
|
+
}
|