create-forgeon 0.0.3 → 0.1.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.
- package/README.md +10 -5
- package/bin/create-forgeon.mjs +9 -604
- package/package.json +6 -2
- package/src/cli/add-help.mjs +12 -0
- package/src/cli/add-options.mjs +54 -0
- package/src/cli/add-options.test.mjs +24 -0
- package/src/cli/help.mjs +20 -0
- package/src/cli/options.mjs +121 -0
- package/src/cli/options.test.mjs +41 -0
- package/src/cli/prompt-select.mjs +89 -0
- package/src/cli/prompt-select.test.mjs +34 -0
- package/src/constants.mjs +13 -0
- package/src/core/docs.mjs +128 -0
- package/src/core/docs.test.mjs +91 -0
- package/src/core/install.mjs +14 -0
- package/src/core/scaffold.mjs +57 -0
- package/src/core/validate.mjs +12 -0
- package/src/core/validate.test.mjs +73 -0
- package/src/databases/index.mjs +26 -0
- package/src/frameworks/index.mjs +32 -0
- package/src/infrastructure/proxy.mjs +12 -0
- package/src/modules/docs.mjs +70 -0
- package/src/modules/executor.mjs +40 -0
- package/src/modules/executor.test.mjs +62 -0
- package/src/modules/registry.mjs +37 -0
- package/src/presets/i18n.mjs +203 -0
- package/src/presets/index.mjs +2 -0
- package/src/presets/proxy.mjs +32 -0
- package/src/run-add-module.mjs +47 -0
- package/src/run-create-forgeon.mjs +72 -0
- package/src/utils/fs.mjs +26 -0
- package/src/utils/values.mjs +20 -0
- package/templates/base/docs/AI/MODULE_SPEC.md +56 -0
- package/templates/base/docs/AI/TASKS.md +17 -7
- package/templates/base/docs/README.md +2 -1
- package/templates/base/infra/caddy/Caddyfile +11 -7
- package/templates/base/infra/docker/compose.none.yml +37 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/00_title.md +1 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/10_layout_base.md +6 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/11_layout_infra.md +1 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/12_layout_i18n_resources.md +1 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/20_env_base.md +4 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/21_env_i18n.md +3 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/30_default_db.md +7 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/31_docker_runtime.md +5 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/32_scope_freeze.md +5 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/40_docs_generation.md +9 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/50_extension_points.md +8 -0
- package/templates/docs-fragments/AI_PROJECT/00_title.md +1 -0
- package/templates/docs-fragments/AI_PROJECT/10_what_is.md +3 -0
- package/templates/docs-fragments/AI_PROJECT/20_structure_base.md +5 -0
- package/templates/docs-fragments/AI_PROJECT/21_structure_i18n.md +2 -0
- package/templates/docs-fragments/AI_PROJECT/22_structure_docker.md +1 -0
- package/templates/docs-fragments/AI_PROJECT/23_structure_docs.md +1 -0
- package/templates/docs-fragments/AI_PROJECT/30_run_dev.md +8 -0
- package/templates/docs-fragments/AI_PROJECT/31_run_docker.md +5 -0
- package/templates/docs-fragments/AI_PROJECT/32_proxy_notes.md +5 -0
- package/templates/docs-fragments/AI_PROJECT/32_proxy_notes_none.md +5 -0
- package/templates/docs-fragments/AI_PROJECT/33_i18n_notes.md +4 -0
- package/templates/docs-fragments/AI_PROJECT/40_change_boundaries_base.md +3 -0
- package/templates/docs-fragments/AI_PROJECT/41_change_boundaries_docker.md +1 -0
- package/templates/docs-fragments/README/00_title.md +3 -0
- package/templates/docs-fragments/README/10_stack.md +8 -0
- package/templates/docs-fragments/README/20_quick_start_dev_intro.md +6 -0
- package/templates/docs-fragments/README/21_quick_start_dev_db_docker.md +4 -0
- package/templates/docs-fragments/README/21_quick_start_dev_db_local.md +1 -0
- package/templates/docs-fragments/README/22_quick_start_dev_outro.md +7 -0
- package/templates/docs-fragments/README/30_quick_start_docker.md +7 -0
- package/templates/docs-fragments/README/30_quick_start_docker_none.md +9 -0
- package/templates/docs-fragments/README/31_proxy_preset_caddy.md +9 -0
- package/templates/docs-fragments/README/31_proxy_preset_nginx.md +8 -0
- package/templates/docs-fragments/README/31_proxy_preset_none.md +6 -0
- package/templates/docs-fragments/README/32_prisma_container_start.md +5 -0
- package/templates/docs-fragments/README/40_i18n.md +10 -0
- package/templates/docs-fragments/README/90_next_steps.md +7 -0
- package/templates/module-fragments/jwt-auth/00_title.md +1 -0
- package/templates/module-fragments/jwt-auth/10_overview.md +6 -0
- package/templates/module-fragments/jwt-auth/20_scope.md +7 -0
- package/templates/module-fragments/jwt-auth/90_status_planned.md +3 -0
- package/templates/module-fragments/queue/00_title.md +1 -0
- package/templates/module-fragments/queue/10_overview.md +6 -0
- package/templates/module-fragments/queue/20_scope.md +7 -0
- package/templates/module-fragments/queue/90_status_planned.md +3 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function parseAddCliArgs(argv) {
|
|
2
|
+
const args = [...argv];
|
|
3
|
+
const options = {
|
|
4
|
+
moduleId: undefined,
|
|
5
|
+
project: '.',
|
|
6
|
+
list: false,
|
|
7
|
+
help: false,
|
|
8
|
+
};
|
|
9
|
+
const positional = [];
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
12
|
+
const arg = args[i];
|
|
13
|
+
if (arg === '--') continue;
|
|
14
|
+
|
|
15
|
+
if (arg === '-h' || arg === '--help') {
|
|
16
|
+
options.help = true;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (arg === '--list') {
|
|
21
|
+
options.list = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (arg.startsWith('--project=')) {
|
|
26
|
+
options.project = arg.split('=')[1] || '.';
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (arg === '--project') {
|
|
31
|
+
if (args[i + 1] && !args[i + 1].startsWith('-')) {
|
|
32
|
+
options.project = args[i + 1];
|
|
33
|
+
i += 1;
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (arg.startsWith('--')) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
positional.push(arg);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (positional.length > 0) {
|
|
46
|
+
[options.moduleId] = positional;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (positional.length > 1 && options.project === '.') {
|
|
50
|
+
options.project = positional[1];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return options;
|
|
54
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseAddCliArgs } from './add-options.mjs';
|
|
4
|
+
|
|
5
|
+
describe('parseAddCliArgs', () => {
|
|
6
|
+
it('parses module id and explicit project', () => {
|
|
7
|
+
const options = parseAddCliArgs(['jwt-auth', '--project', './demo']);
|
|
8
|
+
assert.equal(options.moduleId, 'jwt-auth');
|
|
9
|
+
assert.equal(options.project, './demo');
|
|
10
|
+
assert.equal(options.list, false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('parses --list and --help', () => {
|
|
14
|
+
const options = parseAddCliArgs(['--list', '--help']);
|
|
15
|
+
assert.equal(options.list, true);
|
|
16
|
+
assert.equal(options.help, true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('uses second positional as project when project flag is absent', () => {
|
|
20
|
+
const options = parseAddCliArgs(['queue', './my-app']);
|
|
21
|
+
assert.equal(options.moduleId, 'queue');
|
|
22
|
+
assert.equal(options.project, './my-app');
|
|
23
|
+
});
|
|
24
|
+
});
|
package/src/cli/help.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function printHelp() {
|
|
2
|
+
console.log(`create-forgeon
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
npx create-forgeon@latest <project-name> [options]
|
|
6
|
+
npx create-forgeon@latest add <module-id> [options]
|
|
7
|
+
npx create-forgeon@latest add --list
|
|
8
|
+
|
|
9
|
+
Create options:
|
|
10
|
+
--i18n <true|false> Enable i18n (default: true)
|
|
11
|
+
--proxy <caddy|nginx|none> Reverse proxy preset (default: caddy)
|
|
12
|
+
--install Run pnpm install after generation
|
|
13
|
+
-y, --yes Skip prompts and use defaults
|
|
14
|
+
-h, --help Show this help
|
|
15
|
+
|
|
16
|
+
Add options:
|
|
17
|
+
--project <path> Target project path (default: current directory)
|
|
18
|
+
--list List available modules
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import readline from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
import { DEFAULT_OPTIONS, DEFAULT_PROXY } from '../constants.mjs';
|
|
4
|
+
import { promptSelect } from './prompt-select.mjs';
|
|
5
|
+
|
|
6
|
+
const REMOVED_FLAGS = new Set(['frontend', 'db', 'docker']);
|
|
7
|
+
|
|
8
|
+
export function parseCliArgs(argv) {
|
|
9
|
+
const args = [...argv];
|
|
10
|
+
const options = { ...DEFAULT_OPTIONS };
|
|
11
|
+
const positional = [];
|
|
12
|
+
|
|
13
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
14
|
+
const arg = args[i];
|
|
15
|
+
|
|
16
|
+
if (arg === '--') continue;
|
|
17
|
+
|
|
18
|
+
if (arg === '-h' || arg === '--help') {
|
|
19
|
+
options.help = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (arg === '-y' || arg === '--yes') {
|
|
24
|
+
options.yes = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (arg === '--install') {
|
|
29
|
+
options.install = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (arg.startsWith('--no-')) {
|
|
34
|
+
const key = arg.slice(5);
|
|
35
|
+
if (REMOVED_FLAGS.has(key)) {
|
|
36
|
+
throw new Error(`Option "--${key}" has been removed. Forgeon now uses a fixed stack.`);
|
|
37
|
+
}
|
|
38
|
+
if (key === 'install') options.install = false;
|
|
39
|
+
if (key === 'i18n') options.i18n = false;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (arg.startsWith('--')) {
|
|
44
|
+
const [keyRaw, inlineValue] = arg.split('=');
|
|
45
|
+
const key = keyRaw.slice(2);
|
|
46
|
+
if (REMOVED_FLAGS.has(key)) {
|
|
47
|
+
throw new Error(`Option "--${key}" has been removed. Forgeon now uses a fixed stack.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let value = inlineValue;
|
|
51
|
+
if (value === undefined && args[i + 1] && !args[i + 1].startsWith('-')) {
|
|
52
|
+
value = args[i + 1];
|
|
53
|
+
i += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (Object.prototype.hasOwnProperty.call(options, key)) {
|
|
57
|
+
options[key] = value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
positional.push(arg);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { options, positional };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function promptForMissingOptions(options) {
|
|
70
|
+
const nextOptions = { ...options };
|
|
71
|
+
|
|
72
|
+
if (!nextOptions.name) {
|
|
73
|
+
if (!input.isTTY) {
|
|
74
|
+
throw new Error('Project name is required in non-interactive mode. Pass it as first argument.');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const rl = readline.createInterface({ input, output });
|
|
78
|
+
try {
|
|
79
|
+
nextOptions.name = await rl.question('Project name: ');
|
|
80
|
+
} finally {
|
|
81
|
+
await rl.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!nextOptions.yes && nextOptions.i18n === undefined) {
|
|
86
|
+
nextOptions.i18n = await promptSelect({
|
|
87
|
+
message: 'Enable i18n:',
|
|
88
|
+
defaultValue: 'true',
|
|
89
|
+
choices: [
|
|
90
|
+
{ label: 'true', value: 'true' },
|
|
91
|
+
{ label: 'false', value: 'false' },
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!nextOptions.yes && !nextOptions.proxy) {
|
|
97
|
+
nextOptions.proxy = await promptSelect({
|
|
98
|
+
message: 'Reverse proxy preset:',
|
|
99
|
+
defaultValue: DEFAULT_PROXY,
|
|
100
|
+
choices: [
|
|
101
|
+
{ label: 'caddy', value: 'caddy' },
|
|
102
|
+
{ label: 'nginx', value: 'nginx' },
|
|
103
|
+
{ label: 'none', value: 'none' },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (nextOptions.yes) {
|
|
109
|
+
if (nextOptions.i18n === undefined) nextOptions.i18n = 'true';
|
|
110
|
+
if (!nextOptions.proxy) nextOptions.proxy = DEFAULT_PROXY;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (nextOptions.i18n === undefined) {
|
|
114
|
+
nextOptions.i18n = 'true';
|
|
115
|
+
}
|
|
116
|
+
if (!nextOptions.proxy) {
|
|
117
|
+
nextOptions.proxy = DEFAULT_PROXY;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return nextOptions;
|
|
121
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseCliArgs } from './options.mjs';
|
|
4
|
+
|
|
5
|
+
describe('parseCliArgs', () => {
|
|
6
|
+
it('parses positional name and supported inline options', () => {
|
|
7
|
+
const { options, positional } = parseCliArgs([
|
|
8
|
+
'demo-app',
|
|
9
|
+
'--i18n=false',
|
|
10
|
+
'--proxy=caddy',
|
|
11
|
+
'--install',
|
|
12
|
+
'--yes',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
assert.equal(positional[0], 'demo-app');
|
|
16
|
+
assert.equal(options.i18n, 'false');
|
|
17
|
+
assert.equal(options.proxy, 'caddy');
|
|
18
|
+
assert.equal(options.install, true);
|
|
19
|
+
assert.equal(options.yes, true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('parses separated option values and negated i18n flag', () => {
|
|
23
|
+
const { options } = parseCliArgs(['--proxy', 'none', '--no-i18n', '--help']);
|
|
24
|
+
|
|
25
|
+
assert.equal(options.proxy, 'none');
|
|
26
|
+
assert.equal(options.i18n, false);
|
|
27
|
+
assert.equal(options.help, true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws for removed stack-selection flags', () => {
|
|
31
|
+
assert.throws(
|
|
32
|
+
() => parseCliArgs(['demo', '--frontend=react']),
|
|
33
|
+
/Option "--frontend" has been removed/,
|
|
34
|
+
);
|
|
35
|
+
assert.throws(() => parseCliArgs(['demo', '--db=prisma']), /Option "--db" has been removed/);
|
|
36
|
+
assert.throws(
|
|
37
|
+
() => parseCliArgs(['demo', '--docker=true']),
|
|
38
|
+
/Option "--docker" has been removed/,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
|
|
4
|
+
export async function promptSelect({
|
|
5
|
+
message,
|
|
6
|
+
choices,
|
|
7
|
+
defaultValue,
|
|
8
|
+
inputStream = input,
|
|
9
|
+
outputStream = output,
|
|
10
|
+
}) {
|
|
11
|
+
if (!Array.isArray(choices) || choices.length === 0) {
|
|
12
|
+
throw new Error('promptSelect requires at least one choice.');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let index = choices.findIndex((choice) => choice.value === defaultValue);
|
|
16
|
+
if (index < 0) index = 0;
|
|
17
|
+
|
|
18
|
+
if (!inputStream.isTTY || !outputStream.isTTY) {
|
|
19
|
+
return choices[index].value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
readline.emitKeypressEvents(inputStream);
|
|
23
|
+
|
|
24
|
+
const canSetRawMode = typeof inputStream.setRawMode === 'function';
|
|
25
|
+
const wasRawModeEnabled = Boolean(inputStream.isRaw);
|
|
26
|
+
if (canSetRawMode && !wasRawModeEnabled) {
|
|
27
|
+
inputStream.setRawMode(true);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let renderedLines = 0;
|
|
31
|
+
|
|
32
|
+
const render = () => {
|
|
33
|
+
if (renderedLines > 0) {
|
|
34
|
+
readline.moveCursor(outputStream, 0, -renderedLines);
|
|
35
|
+
readline.cursorTo(outputStream, 0);
|
|
36
|
+
readline.clearScreenDown(outputStream);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
outputStream.write(`${message}\n`);
|
|
40
|
+
for (let i = 0; i < choices.length; i += 1) {
|
|
41
|
+
const marker = i === index ? '>' : ' ';
|
|
42
|
+
outputStream.write(`${marker} ${choices[i].label}\n`);
|
|
43
|
+
}
|
|
44
|
+
outputStream.write('Use Up/Down arrows and Enter.\n');
|
|
45
|
+
|
|
46
|
+
renderedLines = choices.length + 2;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
inputStream.off('keypress', onKeypress);
|
|
52
|
+
if (canSetRawMode && !wasRawModeEnabled) {
|
|
53
|
+
inputStream.setRawMode(false);
|
|
54
|
+
}
|
|
55
|
+
outputStream.write('\n');
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const onKeypress = (_str, key) => {
|
|
59
|
+
if (!key) return;
|
|
60
|
+
|
|
61
|
+
if (key.ctrl && key.name === 'c') {
|
|
62
|
+
cleanup();
|
|
63
|
+
reject(new Error('Prompt cancelled.'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (key.name === 'up') {
|
|
68
|
+
index = (index - 1 + choices.length) % choices.length;
|
|
69
|
+
render();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (key.name === 'down') {
|
|
74
|
+
index = (index + 1) % choices.length;
|
|
75
|
+
render();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
80
|
+
const selected = choices[index].value;
|
|
81
|
+
cleanup();
|
|
82
|
+
resolve(selected);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
render();
|
|
87
|
+
inputStream.on('keypress', onKeypress);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { promptSelect } from './prompt-select.mjs';
|
|
4
|
+
|
|
5
|
+
describe('promptSelect', () => {
|
|
6
|
+
it('returns default choice in non-tty mode', async () => {
|
|
7
|
+
const value = await promptSelect({
|
|
8
|
+
message: 'Pick one',
|
|
9
|
+
defaultValue: 'b',
|
|
10
|
+
choices: [
|
|
11
|
+
{ label: 'A', value: 'a' },
|
|
12
|
+
{ label: 'B', value: 'b' },
|
|
13
|
+
],
|
|
14
|
+
inputStream: { isTTY: false },
|
|
15
|
+
outputStream: { isTTY: false },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
assert.equal(value, 'b');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('throws for empty choices', async () => {
|
|
22
|
+
await assert.rejects(
|
|
23
|
+
() =>
|
|
24
|
+
promptSelect({
|
|
25
|
+
message: 'Pick one',
|
|
26
|
+
defaultValue: 'a',
|
|
27
|
+
choices: [],
|
|
28
|
+
inputStream: { isTTY: false },
|
|
29
|
+
outputStream: { isTTY: false },
|
|
30
|
+
}),
|
|
31
|
+
/requires at least one choice/,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const DEFAULT_FRONTEND = 'react';
|
|
2
|
+
export const DEFAULT_DB = 'prisma';
|
|
3
|
+
export const DEFAULT_PROXY = 'caddy';
|
|
4
|
+
export const FIXED_DOCKER_ENABLED = true;
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_OPTIONS = {
|
|
7
|
+
name: undefined,
|
|
8
|
+
i18n: undefined,
|
|
9
|
+
proxy: undefined,
|
|
10
|
+
install: false,
|
|
11
|
+
yes: false,
|
|
12
|
+
help: false,
|
|
13
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getDatabaseLabel } from '../databases/index.mjs';
|
|
4
|
+
import { getFrontendLabel } from '../frameworks/index.mjs';
|
|
5
|
+
import { getProxyConfigPath } from '../infrastructure/proxy.mjs';
|
|
6
|
+
|
|
7
|
+
function renderTemplate(content, variables) {
|
|
8
|
+
return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => String(variables[key] ?? ''));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readFragment(fragmentsRoot, docKey, fragmentName, variables) {
|
|
12
|
+
const fragmentPath = path.join(fragmentsRoot, docKey, `${fragmentName}.md`);
|
|
13
|
+
if (!fs.existsSync(fragmentPath)) {
|
|
14
|
+
throw new Error(`Missing docs fragment: ${fragmentPath}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const raw = fs.readFileSync(fragmentPath, 'utf8').replace(/\r\n/g, '\n').trim();
|
|
18
|
+
return renderTemplate(raw, variables).trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeDocFromFragments({
|
|
22
|
+
targetRoot,
|
|
23
|
+
outputPath,
|
|
24
|
+
fragmentsRoot,
|
|
25
|
+
docKey,
|
|
26
|
+
fragmentNames,
|
|
27
|
+
variables,
|
|
28
|
+
}) {
|
|
29
|
+
const fragments = fragmentNames
|
|
30
|
+
.map((fragmentName) => readFragment(fragmentsRoot, docKey, fragmentName, variables))
|
|
31
|
+
.filter((fragment) => fragment.length > 0);
|
|
32
|
+
|
|
33
|
+
const content = `${fragments
|
|
34
|
+
.join('\n\n')
|
|
35
|
+
.replace(/\n{2,}(?=- )/g, '\n')
|
|
36
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
37
|
+
.trimEnd()}\n`;
|
|
38
|
+
const absoluteOutputPath = path.join(targetRoot, outputPath);
|
|
39
|
+
fs.mkdirSync(path.dirname(absoluteOutputPath), { recursive: true });
|
|
40
|
+
fs.writeFileSync(absoluteOutputPath, content, 'utf8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function generateDocs(targetRoot, options, packageRoot) {
|
|
44
|
+
const fragmentsRoot = path.resolve(packageRoot, 'templates', 'docs-fragments');
|
|
45
|
+
const variables = {
|
|
46
|
+
FRONTEND_LABEL: getFrontendLabel(options.frontend),
|
|
47
|
+
DB_LABEL: getDatabaseLabel(options.db),
|
|
48
|
+
I18N_STATUS: options.i18nEnabled ? 'enabled' : 'disabled',
|
|
49
|
+
DOCKER_STATUS: 'enabled',
|
|
50
|
+
PROXY_LABEL: options.proxy,
|
|
51
|
+
PROXY_CONFIG_PATH: getProxyConfigPath(options.proxy),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const readmeFragments = ['00_title', '10_stack', '20_quick_start_dev_intro'];
|
|
55
|
+
readmeFragments.push('21_quick_start_dev_db_docker');
|
|
56
|
+
readmeFragments.push('22_quick_start_dev_outro');
|
|
57
|
+
readmeFragments.push(
|
|
58
|
+
options.proxy === 'none' ? '30_quick_start_docker_none' : '30_quick_start_docker',
|
|
59
|
+
);
|
|
60
|
+
if (options.proxy === 'caddy') {
|
|
61
|
+
readmeFragments.push('31_proxy_preset_caddy');
|
|
62
|
+
} else if (options.proxy === 'nginx') {
|
|
63
|
+
readmeFragments.push('31_proxy_preset_nginx');
|
|
64
|
+
} else {
|
|
65
|
+
readmeFragments.push('31_proxy_preset_none');
|
|
66
|
+
}
|
|
67
|
+
readmeFragments.push('32_prisma_container_start');
|
|
68
|
+
if (options.i18nEnabled) {
|
|
69
|
+
readmeFragments.push('40_i18n');
|
|
70
|
+
}
|
|
71
|
+
readmeFragments.push('90_next_steps');
|
|
72
|
+
|
|
73
|
+
const aiProjectFragments = ['00_title', '10_what_is', '20_structure_base'];
|
|
74
|
+
if (options.i18nEnabled) {
|
|
75
|
+
aiProjectFragments.push('21_structure_i18n');
|
|
76
|
+
}
|
|
77
|
+
aiProjectFragments.push('22_structure_docker', '23_structure_docs', '30_run_dev', '31_run_docker');
|
|
78
|
+
if (options.proxy === 'none') {
|
|
79
|
+
aiProjectFragments.push('32_proxy_notes_none');
|
|
80
|
+
} else {
|
|
81
|
+
aiProjectFragments.push('32_proxy_notes');
|
|
82
|
+
}
|
|
83
|
+
if (options.i18nEnabled) {
|
|
84
|
+
aiProjectFragments.push('33_i18n_notes');
|
|
85
|
+
}
|
|
86
|
+
aiProjectFragments.push('40_change_boundaries_base');
|
|
87
|
+
if (options.proxy !== 'none') {
|
|
88
|
+
aiProjectFragments.push('41_change_boundaries_docker');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const aiArchitectureFragments = ['00_title', '10_layout_base', '11_layout_infra'];
|
|
92
|
+
if (options.i18nEnabled) {
|
|
93
|
+
aiArchitectureFragments.push('12_layout_i18n_resources');
|
|
94
|
+
}
|
|
95
|
+
aiArchitectureFragments.push('20_env_base');
|
|
96
|
+
if (options.i18nEnabled) {
|
|
97
|
+
aiArchitectureFragments.push('21_env_i18n');
|
|
98
|
+
}
|
|
99
|
+
aiArchitectureFragments.push('30_default_db', '31_docker_runtime', '32_scope_freeze');
|
|
100
|
+
aiArchitectureFragments.push('40_docs_generation', '50_extension_points');
|
|
101
|
+
|
|
102
|
+
writeDocFromFragments({
|
|
103
|
+
targetRoot,
|
|
104
|
+
outputPath: 'README.md',
|
|
105
|
+
fragmentsRoot,
|
|
106
|
+
docKey: 'README',
|
|
107
|
+
fragmentNames: readmeFragments,
|
|
108
|
+
variables,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
writeDocFromFragments({
|
|
112
|
+
targetRoot,
|
|
113
|
+
outputPath: path.join('docs', 'AI', 'PROJECT.md'),
|
|
114
|
+
fragmentsRoot,
|
|
115
|
+
docKey: 'AI_PROJECT',
|
|
116
|
+
fragmentNames: aiProjectFragments,
|
|
117
|
+
variables,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
writeDocFromFragments({
|
|
121
|
+
targetRoot,
|
|
122
|
+
outputPath: path.join('docs', 'AI', 'ARCHITECTURE.md'),
|
|
123
|
+
fragmentsRoot,
|
|
124
|
+
docKey: 'AI_ARCHITECTURE',
|
|
125
|
+
fragmentNames: aiArchitectureFragments,
|
|
126
|
+
variables,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { generateDocs } from './docs.mjs';
|
|
8
|
+
|
|
9
|
+
function makeTempDir(prefix) {
|
|
10
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readFile(filePath) {
|
|
14
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('generateDocs', () => {
|
|
18
|
+
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const packageRoot = path.resolve(thisDir, '..', '..');
|
|
20
|
+
|
|
21
|
+
it('generates docs for proxy=none without i18n section', () => {
|
|
22
|
+
const targetRoot = makeTempDir('forgeon-docs-off-');
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
generateDocs(
|
|
26
|
+
targetRoot,
|
|
27
|
+
{
|
|
28
|
+
frontend: 'react',
|
|
29
|
+
db: 'prisma',
|
|
30
|
+
dockerEnabled: true,
|
|
31
|
+
i18nEnabled: false,
|
|
32
|
+
proxy: 'none',
|
|
33
|
+
},
|
|
34
|
+
packageRoot,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const readme = readFile(path.join(targetRoot, 'README.md'));
|
|
38
|
+
const projectDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'PROJECT.md'));
|
|
39
|
+
const architectureDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'ARCHITECTURE.md'));
|
|
40
|
+
|
|
41
|
+
assert.match(readme, /Docker\/infra: `enabled`/);
|
|
42
|
+
assert.match(readme, /Quick Start \(Docker\)/);
|
|
43
|
+
assert.match(readme, /Proxy Preset: none/);
|
|
44
|
+
assert.doesNotMatch(readme, /i18n Configuration/);
|
|
45
|
+
|
|
46
|
+
assert.match(projectDoc, /### Docker mode/);
|
|
47
|
+
assert.match(projectDoc, /Active proxy preset: `none`/);
|
|
48
|
+
assert.doesNotMatch(projectDoc, /packages\/i18n/);
|
|
49
|
+
|
|
50
|
+
assert.match(architectureDoc, /infra\/\*/);
|
|
51
|
+
assert.doesNotMatch(architectureDoc, /I18N_ENABLED/);
|
|
52
|
+
} finally {
|
|
53
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('generates docker and caddy notes when enabled', () => {
|
|
58
|
+
const targetRoot = makeTempDir('forgeon-docs-on-');
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
generateDocs(
|
|
62
|
+
targetRoot,
|
|
63
|
+
{
|
|
64
|
+
frontend: 'react',
|
|
65
|
+
db: 'prisma',
|
|
66
|
+
dockerEnabled: true,
|
|
67
|
+
i18nEnabled: true,
|
|
68
|
+
proxy: 'caddy',
|
|
69
|
+
},
|
|
70
|
+
packageRoot,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const readme = readFile(path.join(targetRoot, 'README.md'));
|
|
74
|
+
const projectDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'PROJECT.md'));
|
|
75
|
+
const architectureDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'ARCHITECTURE.md'));
|
|
76
|
+
|
|
77
|
+
assert.match(readme, /Quick Start \(Docker\)/);
|
|
78
|
+
assert.match(readme, /Proxy Preset: Caddy/);
|
|
79
|
+
assert.match(readme, /i18n Configuration/);
|
|
80
|
+
|
|
81
|
+
assert.match(projectDoc, /`infra` - Docker Compose \(always\) \+ proxy preset \(`caddy`\)/);
|
|
82
|
+
assert.match(projectDoc, /Main proxy config: `infra\/caddy\/Caddyfile`/);
|
|
83
|
+
|
|
84
|
+
assert.match(architectureDoc, /infra\/\*/);
|
|
85
|
+
assert.match(architectureDoc, /I18N_ENABLED/);
|
|
86
|
+
assert.match(architectureDoc, /Active reverse proxy preset: `caddy`/);
|
|
87
|
+
} finally {
|
|
88
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function runInstall(targetRoot) {
|
|
4
|
+
const pnpmCmd = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
|
5
|
+
const result = spawnSync(pnpmCmd, ['install'], {
|
|
6
|
+
cwd: targetRoot,
|
|
7
|
+
stdio: 'inherit',
|
|
8
|
+
shell: false,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
if (result.status !== 0) {
|
|
12
|
+
process.exit(result.status ?? 1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { copyRecursive, writeJson } from '../utils/fs.mjs';
|
|
4
|
+
import { toKebabCase } from '../utils/values.mjs';
|
|
5
|
+
import { applyI18nDisabled, applyProxyPreset, patchDockerEnvForI18n } from '../presets/index.mjs';
|
|
6
|
+
import { generateDocs } from './docs.mjs';
|
|
7
|
+
|
|
8
|
+
function writeApiEnvExample(targetRoot, i18nEnabled) {
|
|
9
|
+
const apiEnvExamplePath = path.join(targetRoot, 'apps', 'api', '.env.example');
|
|
10
|
+
const apiEnvLines = [
|
|
11
|
+
'PORT=3000',
|
|
12
|
+
'DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app?schema=public',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
if (i18nEnabled) {
|
|
16
|
+
apiEnvLines.push('I18N_ENABLED=true');
|
|
17
|
+
apiEnvLines.push('I18N_DEFAULT_LANG=en');
|
|
18
|
+
apiEnvLines.push('I18N_FALLBACK_LANG=en');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fs.writeFileSync(apiEnvExamplePath, `${apiEnvLines.join('\n')}\n`, 'utf8');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function patchRootPackageJson(targetRoot, projectName) {
|
|
25
|
+
const rootPackageJsonPath = path.join(targetRoot, 'package.json');
|
|
26
|
+
const rootPackageJson = JSON.parse(fs.readFileSync(rootPackageJsonPath, 'utf8'));
|
|
27
|
+
rootPackageJson.name = toKebabCase(projectName);
|
|
28
|
+
|
|
29
|
+
if (rootPackageJson.scripts) {
|
|
30
|
+
delete rootPackageJson.scripts['create:forgeon'];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
writeJson(rootPackageJsonPath, rootPackageJson);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function scaffoldProject({
|
|
37
|
+
templateRoot,
|
|
38
|
+
packageRoot,
|
|
39
|
+
targetRoot,
|
|
40
|
+
projectName,
|
|
41
|
+
frontend,
|
|
42
|
+
db,
|
|
43
|
+
i18nEnabled,
|
|
44
|
+
proxy,
|
|
45
|
+
}) {
|
|
46
|
+
copyRecursive(templateRoot, targetRoot);
|
|
47
|
+
patchRootPackageJson(targetRoot, projectName);
|
|
48
|
+
applyProxyPreset(targetRoot, proxy);
|
|
49
|
+
patchDockerEnvForI18n(targetRoot, i18nEnabled);
|
|
50
|
+
|
|
51
|
+
if (!i18nEnabled) {
|
|
52
|
+
applyI18nDisabled(targetRoot);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
writeApiEnvExample(targetRoot, i18nEnabled);
|
|
56
|
+
generateDocs(targetRoot, { frontend, db, dockerEnabled: true, i18nEnabled, proxy }, packageRoot);
|
|
57
|
+
}
|