@ecc-hgy/ae 0.2.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/LICENSE +21 -0
- package/README.md +108 -0
- package/bin/ae.js +2 -0
- package/package.json +43 -0
- package/skills/brainstorming/SKILL.md +133 -0
- package/skills/diagnose/SKILL.md +146 -0
- package/skills/diagnose/assets/issue-7-sections.md +35 -0
- package/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/skills/grill-me/SKILL.md +10 -0
- package/skills/handoff/SKILL.md +19 -0
- package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
- package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/skills/improve-codebase-architecture/SKILL.md +101 -0
- package/skills/review/SKILL.md +119 -0
- package/skills/tdd/SKILL.md +157 -0
- package/skills/tdd/deep-modules.md +33 -0
- package/skills/tdd/interface-design.md +31 -0
- package/skills/tdd/mocking.md +59 -0
- package/skills/tdd/refactoring.md +10 -0
- package/skills/tdd/tests.md +61 -0
- package/skills/to-issues/SKILL.md +79 -0
- package/skills/to-issues/todo-template.md +25 -0
- package/skills/to-prd/SKILL.md +108 -0
- package/skills/verification-before-completion/SKILL.md +153 -0
- package/skills/writing-plans/SKILL.md +115 -0
- package/skills/zoom-out/SKILL.md +7 -0
- package/src/cli.js +62 -0
- package/src/commands/init.js +190 -0
- package/src/commands/setup.js +111 -0
- package/src/skeleton.js +209 -0
- package/src/utils/copy.js +100 -0
- package/src/utils/paths.js +60 -0
- package/src/utils/report.js +31 -0
- package/templates/ae-rules.md +17 -0
- package/templates/entries/AGENTS.md +21 -0
- package/templates/entries/CLAUDE.md +21 -0
- package/templates/entries/README.md +18 -0
- package/templates/entries/handoff.md +1 -0
- package/templates/entries/spec/INDEX.md +15 -0
- package/templates/entries/spec/README.md +19 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { packageRoot } from './utils/paths.js';
|
|
4
|
+
|
|
5
|
+
const HELP = `Usage: ae <command> [options]
|
|
6
|
+
|
|
7
|
+
Commands:
|
|
8
|
+
setup Install skills to .claude/.codex and drop a config/ae-rules.md template.
|
|
9
|
+
init [goal] Create spec/ skeleton, render entry files, sync AE:RULES block from
|
|
10
|
+
config/ae-rules.md. Requires \`ae setup\` to have run.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
-h, --help Show help
|
|
14
|
+
-v, --version Show version
|
|
15
|
+
|
|
16
|
+
Typical workflow:
|
|
17
|
+
ae setup
|
|
18
|
+
[optional] edit config/ae-rules.md
|
|
19
|
+
ae init "<one-line project goal>"`;
|
|
20
|
+
|
|
21
|
+
export async function run(argv = []) {
|
|
22
|
+
try {
|
|
23
|
+
const [command, ...rest] = argv;
|
|
24
|
+
|
|
25
|
+
if (!command || command === '--help' || command === '-h') {
|
|
26
|
+
console.log(HELP);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (command === '--version' || command === '-v') {
|
|
31
|
+
console.log(await readVersion());
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (command === 'setup') {
|
|
36
|
+
const mod = await import('./commands/setup.js');
|
|
37
|
+
return await mod.run(rest);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (command === 'init') {
|
|
41
|
+
const mod = await import('./commands/init.js');
|
|
42
|
+
return await mod.run(rest);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.error(`Unknown command: ${command}`);
|
|
46
|
+
console.error('');
|
|
47
|
+
console.error(HELP);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return 1;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readVersion() {
|
|
58
|
+
const packageJson = JSON.parse(
|
|
59
|
+
await readFile(path.join(packageRoot(), 'package.json'), 'utf8'),
|
|
60
|
+
);
|
|
61
|
+
return packageJson.version;
|
|
62
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { mkdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createDirectories, renderEntryFiles, syncRulesBlock, RulesMarkerError } from '../skeleton.js';
|
|
4
|
+
import { templatesPath } from '../utils/paths.js';
|
|
5
|
+
import { formatInitReport } from '../utils/report.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_GOAL = 'TODO: 用一句话说明这个项目要解决什么问题。';
|
|
8
|
+
|
|
9
|
+
const HELP = `Usage: ae init [goal] [options]
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--target <path> Initialize this project directory. Defaults to cwd.
|
|
13
|
+
--rules <path> Read AE:RULES content from this markdown file.
|
|
14
|
+
Defaults to <target>/config/ae-rules.md when present.
|
|
15
|
+
--dry-run Show what would be created or synced without writing files.
|
|
16
|
+
-h, --help Show help`;
|
|
17
|
+
|
|
18
|
+
export async function run(argv = []) {
|
|
19
|
+
const options = parseArgs(argv);
|
|
20
|
+
|
|
21
|
+
if (options.help) {
|
|
22
|
+
console.log(HELP);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const target = path.resolve(options.target ?? process.cwd());
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await ensureTargetDirectory(target, options.dryRun);
|
|
30
|
+
if (!(await hasInstalledAssets(target))) {
|
|
31
|
+
console.error(`Error: AE assets not found in ${target}.`);
|
|
32
|
+
console.error('Run `ae setup` first.');
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const projectName = path.basename(target);
|
|
38
|
+
const projectGoal = options.goal ?? DEFAULT_GOAL;
|
|
39
|
+
const created = [];
|
|
40
|
+
const skipped = [];
|
|
41
|
+
|
|
42
|
+
const dirs = await createDirectories(target, { dryRun: options.dryRun });
|
|
43
|
+
created.push(...dirs.created);
|
|
44
|
+
skipped.push(...dirs.skipped);
|
|
45
|
+
|
|
46
|
+
const entries = await renderEntryFiles(target, templatesPath('entries'), {
|
|
47
|
+
projectName,
|
|
48
|
+
projectGoal,
|
|
49
|
+
dryRun: options.dryRun,
|
|
50
|
+
});
|
|
51
|
+
created.push(...entries.created);
|
|
52
|
+
skipped.push(...entries.skipped);
|
|
53
|
+
|
|
54
|
+
const rules = await syncRules(target, options);
|
|
55
|
+
|
|
56
|
+
console.log(formatInitReport({ created, skipped, target, rules, dryRun: options.dryRun }));
|
|
57
|
+
return 0;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof RulesMarkerError) {
|
|
60
|
+
console.error(`AE init failed: ${error.message}`);
|
|
61
|
+
} else {
|
|
62
|
+
console.error(`AE init failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
63
|
+
}
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseArgs(argv) {
|
|
70
|
+
const options = {
|
|
71
|
+
target: undefined,
|
|
72
|
+
rules: undefined,
|
|
73
|
+
goalParts: [],
|
|
74
|
+
dryRun: false,
|
|
75
|
+
help: false,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
79
|
+
const arg = argv[index];
|
|
80
|
+
if (arg === '--help' || arg === '-h') {
|
|
81
|
+
options.help = true;
|
|
82
|
+
} else if (arg === '--dry-run') {
|
|
83
|
+
options.dryRun = true;
|
|
84
|
+
} else if (arg === '--target') {
|
|
85
|
+
const value = argv[index + 1];
|
|
86
|
+
if (!value) {
|
|
87
|
+
throw new Error('--target requires a path');
|
|
88
|
+
}
|
|
89
|
+
options.target = value;
|
|
90
|
+
index += 1;
|
|
91
|
+
} else if (arg === '--rules') {
|
|
92
|
+
const value = argv[index + 1];
|
|
93
|
+
if (!value) {
|
|
94
|
+
throw new Error('--rules requires a path');
|
|
95
|
+
}
|
|
96
|
+
options.rules = value;
|
|
97
|
+
index += 1;
|
|
98
|
+
} else if (arg.startsWith('-')) {
|
|
99
|
+
throw new Error(`Unknown option for init: ${arg}`);
|
|
100
|
+
} else {
|
|
101
|
+
options.goalParts.push(arg);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
target: options.target,
|
|
107
|
+
rules: options.rules,
|
|
108
|
+
dryRun: options.dryRun,
|
|
109
|
+
help: options.help,
|
|
110
|
+
goal: options.goalParts.length > 0 ? options.goalParts.join(' ') : undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function ensureTargetDirectory(target, dryRun) {
|
|
115
|
+
try {
|
|
116
|
+
const current = await stat(target);
|
|
117
|
+
if (!current.isDirectory()) {
|
|
118
|
+
throw new Error(`target exists but is not a directory: ${target}`);
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error?.code !== 'ENOENT') {
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
if (!dryRun) {
|
|
125
|
+
await mkdir(target, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function hasInstalledAssets(target) {
|
|
131
|
+
return (
|
|
132
|
+
(await isDirectory(path.join(target, '.claude', 'skills'))) ||
|
|
133
|
+
(await isDirectory(path.join(target, '.codex', 'skills')))
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function syncRules(target, options) {
|
|
138
|
+
const rulesPath = options.rules
|
|
139
|
+
? path.resolve(process.cwd(), options.rules)
|
|
140
|
+
: path.join(target, 'config', 'ae-rules.md');
|
|
141
|
+
|
|
142
|
+
const rulesInfo = await statMaybe(rulesPath);
|
|
143
|
+
if (!rulesInfo) {
|
|
144
|
+
if (options.rules) {
|
|
145
|
+
throw new Error(`rules file does not exist: ${rulesPath}`);
|
|
146
|
+
}
|
|
147
|
+
return 'skipped (no rules file)';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (rulesInfo.isDirectory()) {
|
|
151
|
+
throw new Error(`expected a file, got directory: ${rulesPath}`);
|
|
152
|
+
}
|
|
153
|
+
if (!rulesInfo.isFile()) {
|
|
154
|
+
throw new Error(`expected a file: ${rulesPath}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const rulesContent = await readFile(rulesPath, 'utf8');
|
|
158
|
+
const agents = path.join(target, 'AGENTS.md');
|
|
159
|
+
const claude = path.join(target, 'CLAUDE.md');
|
|
160
|
+
const agentsExists = await exists(agents);
|
|
161
|
+
const claudeExists = await exists(claude);
|
|
162
|
+
|
|
163
|
+
if (options.dryRun && (!agentsExists || !claudeExists)) {
|
|
164
|
+
return 'would sync';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await syncRulesBlock(agents, rulesContent, { dryRun: options.dryRun });
|
|
168
|
+
await syncRulesBlock(claude, rulesContent, { dryRun: options.dryRun });
|
|
169
|
+
return options.dryRun ? 'would sync' : 'synced';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function statMaybe(value) {
|
|
173
|
+
try {
|
|
174
|
+
return await stat(value);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error?.code === 'ENOENT') {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function exists(value) {
|
|
184
|
+
return Boolean(await statMaybe(value));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function isDirectory(value) {
|
|
188
|
+
const current = await statMaybe(value);
|
|
189
|
+
return Boolean(current?.isDirectory());
|
|
190
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { copyFileIdempotent, copyTreeIdempotent } from '../utils/copy.js';
|
|
4
|
+
import { skillsPath, templatesPath } from '../utils/paths.js';
|
|
5
|
+
import { formatSetupReport } from '../utils/report.js';
|
|
6
|
+
|
|
7
|
+
const HELP = `Usage: ae setup [options]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--target <path> Install AE assets into this project directory. Defaults to cwd.
|
|
11
|
+
--dry-run Show what would be installed without writing files.
|
|
12
|
+
-h, --help Show help`;
|
|
13
|
+
|
|
14
|
+
export async function run(argv = []) {
|
|
15
|
+
const options = parseArgs(argv);
|
|
16
|
+
|
|
17
|
+
if (options.help) {
|
|
18
|
+
console.log(HELP);
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const target = path.resolve(options.target ?? process.cwd());
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await ensureTargetDirectory(target, options.dryRun);
|
|
26
|
+
|
|
27
|
+
const created = [];
|
|
28
|
+
const skipped = [];
|
|
29
|
+
const skillGroups = [
|
|
30
|
+
{
|
|
31
|
+
dest: path.join(target, '.claude', 'skills'),
|
|
32
|
+
prefix: path.join('.claude', 'skills'),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
dest: path.join(target, '.codex', 'skills'),
|
|
36
|
+
prefix: path.join('.codex', 'skills'),
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
for (const group of skillGroups) {
|
|
41
|
+
const result = await copyTreeIdempotent(skillsPath(), group.dest, {
|
|
42
|
+
dryRun: options.dryRun,
|
|
43
|
+
});
|
|
44
|
+
created.push(...result.created.map((item) => toDisplayPath(path.join(group.prefix, item))));
|
|
45
|
+
skipped.push(...result.skipped.map((item) => toDisplayPath(path.join(group.prefix, item))));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rulesResult = await copyFileIdempotent(
|
|
49
|
+
templatesPath('ae-rules.md'),
|
|
50
|
+
path.join(target, 'config', 'ae-rules.md'),
|
|
51
|
+
{ dryRun: options.dryRun },
|
|
52
|
+
);
|
|
53
|
+
created.push(...rulesResult.created.map((item) => toDisplayPath(path.join('config', item))));
|
|
54
|
+
skipped.push(...rulesResult.skipped.map((item) => toDisplayPath(path.join('config', item))));
|
|
55
|
+
|
|
56
|
+
console.log(formatSetupReport({ created, skipped, target, dryRun: options.dryRun }));
|
|
57
|
+
return 0;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(`AE setup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseArgs(argv) {
|
|
66
|
+
const options = {
|
|
67
|
+
target: undefined,
|
|
68
|
+
dryRun: false,
|
|
69
|
+
help: false,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
73
|
+
const arg = argv[index];
|
|
74
|
+
if (arg === '--help' || arg === '-h') {
|
|
75
|
+
options.help = true;
|
|
76
|
+
} else if (arg === '--dry-run') {
|
|
77
|
+
options.dryRun = true;
|
|
78
|
+
} else if (arg === '--target') {
|
|
79
|
+
const value = argv[index + 1];
|
|
80
|
+
if (!value) {
|
|
81
|
+
throw new Error('--target requires a path');
|
|
82
|
+
}
|
|
83
|
+
options.target = value;
|
|
84
|
+
index += 1;
|
|
85
|
+
} else {
|
|
86
|
+
throw new Error(`Unknown option for setup: ${arg}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return options;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function ensureTargetDirectory(target, dryRun) {
|
|
94
|
+
try {
|
|
95
|
+
const current = await stat(target);
|
|
96
|
+
if (!current.isDirectory()) {
|
|
97
|
+
throw new Error(`target exists but is not a directory: ${target}`);
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error?.code !== 'ENOENT') {
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
if (!dryRun) {
|
|
104
|
+
await mkdir(target, { recursive: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toDisplayPath(value) {
|
|
110
|
+
return value.split(path.sep).join('/');
|
|
111
|
+
}
|
package/src/skeleton.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const RULES_BEGIN = '<!-- AE:RULES:BEGIN -->';
|
|
5
|
+
export const RULES_END = '<!-- AE:RULES:END -->';
|
|
6
|
+
const RULES_COMMENT = '<!-- 由 ae init 从 config/ae-rules.md 或 --rules 指定文件同步,勿手改 -->';
|
|
7
|
+
const DEFAULT_DRY_RUN = false;
|
|
8
|
+
|
|
9
|
+
const SKELETON_DIRS = [
|
|
10
|
+
'spec',
|
|
11
|
+
path.join('spec', 'needs'),
|
|
12
|
+
path.join('spec', 'ADR'),
|
|
13
|
+
'reference',
|
|
14
|
+
'data',
|
|
15
|
+
'scripts',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const GITKEEP_DIRS = [
|
|
19
|
+
path.join('spec', 'needs'),
|
|
20
|
+
path.join('spec', 'ADR'),
|
|
21
|
+
'reference',
|
|
22
|
+
'data',
|
|
23
|
+
'scripts',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const ENTRY_FILES = [
|
|
27
|
+
'README.md',
|
|
28
|
+
'AGENTS.md',
|
|
29
|
+
'CLAUDE.md',
|
|
30
|
+
'handoff.md',
|
|
31
|
+
path.join('spec', 'README.md'),
|
|
32
|
+
path.join('spec', 'INDEX.md'),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export class RulesMarkerError extends Error {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'RulesMarkerError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createDirectories(target, options = {}) {
|
|
43
|
+
const dryRun = Boolean(options.dryRun ?? DEFAULT_DRY_RUN);
|
|
44
|
+
const created = [];
|
|
45
|
+
const skipped = [];
|
|
46
|
+
const updated = [];
|
|
47
|
+
|
|
48
|
+
for (const dir of SKELETON_DIRS) {
|
|
49
|
+
const absolute = path.join(target, dir);
|
|
50
|
+
const display = `${normalizeRelative(dir)}/`;
|
|
51
|
+
|
|
52
|
+
if (await exists(absolute)) {
|
|
53
|
+
const current = await stat(absolute);
|
|
54
|
+
if (!current.isDirectory()) {
|
|
55
|
+
throw new Error(`expected directory but found file: ${absolute}`);
|
|
56
|
+
}
|
|
57
|
+
skipped.push(display);
|
|
58
|
+
} else {
|
|
59
|
+
created.push(display);
|
|
60
|
+
if (!dryRun) {
|
|
61
|
+
await mkdir(absolute, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const dir of GITKEEP_DIRS) {
|
|
67
|
+
const file = path.join(target, dir, '.gitkeep');
|
|
68
|
+
const display = normalizeRelative(path.join(dir, '.gitkeep'));
|
|
69
|
+
|
|
70
|
+
if (await exists(file)) {
|
|
71
|
+
skipped.push(display);
|
|
72
|
+
} else {
|
|
73
|
+
created.push(display);
|
|
74
|
+
if (!dryRun) {
|
|
75
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
76
|
+
await writeFile(file, '');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { created, skipped, updated };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function renderEntryFiles(target, templateRoot, options = {}) {
|
|
85
|
+
const dryRun = Boolean(options.dryRun ?? DEFAULT_DRY_RUN);
|
|
86
|
+
const projectName = options.projectName ?? path.basename(target);
|
|
87
|
+
const projectGoal = options.projectGoal ?? 'TODO: 用一句话说明这个项目要解决什么问题。';
|
|
88
|
+
const created = [];
|
|
89
|
+
const skipped = [];
|
|
90
|
+
const updated = [];
|
|
91
|
+
|
|
92
|
+
for (const file of ENTRY_FILES) {
|
|
93
|
+
const source = path.join(templateRoot, file);
|
|
94
|
+
const dest = path.join(target, file);
|
|
95
|
+
const display = normalizeRelative(file);
|
|
96
|
+
|
|
97
|
+
if (await exists(dest)) {
|
|
98
|
+
skipped.push(display);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const template = await readFile(source, 'utf8');
|
|
103
|
+
const rendered = renderTemplate(template, { projectName, projectGoal });
|
|
104
|
+
created.push(display);
|
|
105
|
+
|
|
106
|
+
if (!dryRun) {
|
|
107
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
108
|
+
await writeFile(dest, rendered);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { created, skipped, updated };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function syncRulesBlock(targetFile, rulesContent, options = {}) {
|
|
116
|
+
const dryRun = Boolean(options.dryRun ?? DEFAULT_DRY_RUN);
|
|
117
|
+
const content = await readFile(targetFile, 'utf8');
|
|
118
|
+
const ranges = findRulesRanges(content, targetFile);
|
|
119
|
+
const block = buildRulesBlock(rulesContent);
|
|
120
|
+
const created = [];
|
|
121
|
+
const skipped = [];
|
|
122
|
+
const updated = [];
|
|
123
|
+
const display = path.basename(targetFile);
|
|
124
|
+
|
|
125
|
+
if (ranges.length === 0) {
|
|
126
|
+
const next = content.endsWith('\n') ? `${content}\n${block}` : `${content}\n\n${block}`;
|
|
127
|
+
if (next === content) {
|
|
128
|
+
skipped.push(display);
|
|
129
|
+
return { created, skipped, updated };
|
|
130
|
+
}
|
|
131
|
+
created.push(`${display}#AE:RULES`);
|
|
132
|
+
if (!dryRun) {
|
|
133
|
+
await writeFile(targetFile, next);
|
|
134
|
+
}
|
|
135
|
+
return { created, skipped, updated };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const [{ start, end }] = ranges;
|
|
139
|
+
const next = `${content.slice(0, start)}${block}${content.slice(end)}`;
|
|
140
|
+
|
|
141
|
+
if (next === content) {
|
|
142
|
+
skipped.push(`${display}#AE:RULES`);
|
|
143
|
+
return { created, skipped, updated };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
updated.push(`${display}#AE:RULES`);
|
|
147
|
+
if (!dryRun) {
|
|
148
|
+
await writeFile(targetFile, next);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { created, skipped, updated };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function buildRulesBlock(rulesContent) {
|
|
155
|
+
const normalized = rulesContent.endsWith('\n') ? rulesContent : `${rulesContent}\n`;
|
|
156
|
+
return `${RULES_BEGIN}\n${RULES_COMMENT}\n${normalized}${RULES_END}\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderTemplate(template, replacements) {
|
|
160
|
+
return template
|
|
161
|
+
.replaceAll('{{PROJECT_NAME}}', replacements.projectName)
|
|
162
|
+
.replaceAll('{{PROJECT_GOAL}}', replacements.projectGoal);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function findRulesRanges(content, targetFile) {
|
|
166
|
+
const beginMatches = [...content.matchAll(new RegExp(escapeRegExp(RULES_BEGIN), 'g'))];
|
|
167
|
+
const endMatches = [...content.matchAll(new RegExp(escapeRegExp(RULES_END), 'g'))];
|
|
168
|
+
|
|
169
|
+
if (beginMatches.length !== endMatches.length || beginMatches.length > 1) {
|
|
170
|
+
throw new RulesMarkerError(`malformed AE:RULES markers in ${targetFile}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (beginMatches.length === 0) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const start = beginMatches[0].index;
|
|
178
|
+
const end = endMatches[0].index + RULES_END.length;
|
|
179
|
+
if (start > endMatches[0].index) {
|
|
180
|
+
throw new RulesMarkerError(`malformed AE:RULES markers in ${targetFile}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let rangeEnd = end;
|
|
184
|
+
if (content.slice(rangeEnd, rangeEnd + 1) === '\n') {
|
|
185
|
+
rangeEnd += 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return [{ start, end: rangeEnd }];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function exists(value) {
|
|
192
|
+
try {
|
|
193
|
+
await stat(value);
|
|
194
|
+
return true;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error?.code === 'ENOENT') {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeRelative(value) {
|
|
204
|
+
return value.split(path.sep).join('/');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function escapeRegExp(value) {
|
|
208
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
209
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { copyFile, chmod, mkdir, readdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function copyTreeIdempotent(srcDir, destDir, options = {}) {
|
|
5
|
+
const dryRun = Boolean(options.dryRun);
|
|
6
|
+
const created = [];
|
|
7
|
+
const skipped = [];
|
|
8
|
+
|
|
9
|
+
const source = await stat(srcDir);
|
|
10
|
+
if (!source.isDirectory()) {
|
|
11
|
+
throw new Error(`source is not a directory: ${srcDir}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
await walk(srcDir, '');
|
|
15
|
+
|
|
16
|
+
return { created, skipped };
|
|
17
|
+
|
|
18
|
+
async function walk(currentSourceDir, relativeDir) {
|
|
19
|
+
const entries = await readdir(currentSourceDir, { withFileTypes: true });
|
|
20
|
+
const currentDestDir = path.join(destDir, relativeDir);
|
|
21
|
+
|
|
22
|
+
if (!dryRun) {
|
|
23
|
+
await mkdir(currentDestDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (entries.length === 0) {
|
|
27
|
+
const marker = normalizeRelative(path.join(relativeDir, '.gitkeep'));
|
|
28
|
+
const markerDest = path.join(currentDestDir, '.gitkeep');
|
|
29
|
+
if (await exists(markerDest)) {
|
|
30
|
+
skipped.push(marker);
|
|
31
|
+
} else {
|
|
32
|
+
created.push(marker);
|
|
33
|
+
if (!dryRun) {
|
|
34
|
+
await writeFile(markerDest, '');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const relativePath = path.join(relativeDir, entry.name);
|
|
42
|
+
const sourcePath = path.join(currentSourceDir, entry.name);
|
|
43
|
+
const destPath = path.join(destDir, relativePath);
|
|
44
|
+
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
await walk(sourcePath, relativePath);
|
|
47
|
+
} else if (entry.isFile()) {
|
|
48
|
+
const normalized = normalizeRelative(relativePath);
|
|
49
|
+
if (await exists(destPath)) {
|
|
50
|
+
skipped.push(normalized);
|
|
51
|
+
} else {
|
|
52
|
+
created.push(normalized);
|
|
53
|
+
if (!dryRun) {
|
|
54
|
+
await mkdir(path.dirname(destPath), { recursive: true });
|
|
55
|
+
await copyFile(sourcePath, destPath);
|
|
56
|
+
const mode = (await stat(sourcePath)).mode & 0o777;
|
|
57
|
+
await chmod(destPath, mode);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function copyFileIdempotent(srcFile, destFile, options = {}) {
|
|
66
|
+
const dryRun = Boolean(options.dryRun);
|
|
67
|
+
const source = await stat(srcFile);
|
|
68
|
+
if (!source.isFile()) {
|
|
69
|
+
throw new Error(`source is not a file: ${srcFile}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const relative = path.basename(destFile);
|
|
73
|
+
if (await exists(destFile)) {
|
|
74
|
+
return { created: [], skipped: [relative] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!dryRun) {
|
|
78
|
+
await mkdir(path.dirname(destFile), { recursive: true });
|
|
79
|
+
await copyFile(srcFile, destFile);
|
|
80
|
+
await chmod(destFile, source.mode & 0o777);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { created: [relative], skipped: [] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function exists(value) {
|
|
87
|
+
try {
|
|
88
|
+
await stat(value);
|
|
89
|
+
return true;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code === 'ENOENT') {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeRelative(value) {
|
|
99
|
+
return value.split(path.sep).join('/');
|
|
100
|
+
}
|