@tyyyho/treg 0.1.2 → 0.1.6
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 +52 -45
- package/dist/init-project/cli.js +228 -0
- package/dist/init-project/frameworks/index.js +39 -0
- package/dist/init-project/frameworks/next/index.js +9 -0
- package/dist/init-project/frameworks/node/index.js +8 -0
- package/dist/init-project/frameworks/nuxt/index.js +9 -0
- package/dist/init-project/frameworks/react/index.js +27 -0
- package/dist/init-project/frameworks/react/v18/index.js +6 -0
- package/dist/init-project/frameworks/react/v19/index.js +6 -0
- package/dist/init-project/frameworks/svelte/index.js +9 -0
- package/dist/init-project/frameworks/vue/index.js +9 -0
- package/dist/init-project/index.js +65 -0
- package/dist/init-project/mrm-core.js +3 -0
- package/dist/init-project/mrm-rules/ai-skills.js +200 -0
- package/dist/init-project/mrm-rules/format.js +44 -0
- package/dist/init-project/mrm-rules/husky.js +61 -0
- package/dist/init-project/mrm-rules/index.js +33 -0
- package/dist/init-project/mrm-rules/lint.js +16 -0
- package/dist/init-project/mrm-rules/shared.js +91 -0
- package/dist/init-project/mrm-rules/test-jest.js +48 -0
- package/dist/init-project/mrm-rules/test-vitest.js +46 -0
- package/dist/init-project/mrm-rules/typescript.js +40 -0
- package/dist/init-project/package-manager.js +57 -0
- package/dist/init-project/types.js +1 -0
- package/dist/init-project/utils.js +9 -0
- package/dist/init-project.js +6 -0
- package/dist/package.json +3 -0
- package/package.json +10 -5
- package/scripts/init-project/cli.mjs +0 -173
- package/scripts/init-project/cli.test.mjs +0 -116
- package/scripts/init-project/frameworks/index.mjs +0 -48
- package/scripts/init-project/frameworks/next/index.mjs +0 -10
- package/scripts/init-project/frameworks/node/index.mjs +0 -8
- package/scripts/init-project/frameworks/nuxt/index.mjs +0 -10
- package/scripts/init-project/frameworks/react/index.mjs +0 -35
- package/scripts/init-project/frameworks/react/v18/index.mjs +0 -6
- package/scripts/init-project/frameworks/react/v19/index.mjs +0 -6
- package/scripts/init-project/frameworks/svelte/index.mjs +0 -10
- package/scripts/init-project/frameworks/vue/index.mjs +0 -10
- package/scripts/init-project/frameworks.test.mjs +0 -63
- package/scripts/init-project/index.mjs +0 -89
- package/scripts/init-project/mrm-core.mjs +0 -5
- package/scripts/init-project/mrm-rules/ai-skills.mjs +0 -220
- package/scripts/init-project/mrm-rules/ai-skills.test.mjs +0 -91
- package/scripts/init-project/mrm-rules/format.mjs +0 -55
- package/scripts/init-project/mrm-rules/husky.mjs +0 -78
- package/scripts/init-project/mrm-rules/index.mjs +0 -35
- package/scripts/init-project/mrm-rules/lint.mjs +0 -18
- package/scripts/init-project/mrm-rules/shared.mjs +0 -61
- package/scripts/init-project/mrm-rules/test-jest.mjs +0 -75
- package/scripts/init-project/mrm-rules/test-vitest.mjs +0 -64
- package/scripts/init-project/mrm-rules/typescript.mjs +0 -44
- package/scripts/init-project/package-manager.mjs +0 -68
- package/scripts/init-project/package-manager.test.mjs +0 -21
- package/scripts/init-project/utils.mjs +0 -12
- package/scripts/init-project/utils.test.mjs +0 -22
- package/scripts/init-project.mjs +0 -7
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const START_MARKER = "<!-- treg:skills:start -->";
|
|
5
|
+
const END_MARKER = "<!-- treg:skills:end -->";
|
|
6
|
+
const SKILLS_BASE_DIR = "skills";
|
|
7
|
+
const FEATURE_SKILLS = {
|
|
8
|
+
format: {
|
|
9
|
+
name: "treg/format",
|
|
10
|
+
description: "Run and verify formatting rules.",
|
|
11
|
+
when: "在提交前或大範圍改動後,統一格式化程式碼。",
|
|
12
|
+
checklist: ["執行 format", "執行 format:check", "確認未變動非目標檔案"],
|
|
13
|
+
},
|
|
14
|
+
husky: {
|
|
15
|
+
name: "treg/husky",
|
|
16
|
+
description: "Verify and maintain git hook automation.",
|
|
17
|
+
when: "需要保證 pre-commit / pre-push 自動檢查時。",
|
|
18
|
+
checklist: [
|
|
19
|
+
"確認 hooks 可執行",
|
|
20
|
+
"確認含 format:check 與 lint:check",
|
|
21
|
+
"若啟用型別/測試,也要納入 hooks",
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
lint: {
|
|
25
|
+
name: "treg/lint",
|
|
26
|
+
description: "Run and validate lint rules.",
|
|
27
|
+
when: "新增規則或調整工具鏈後,驗證 lint 一致性。",
|
|
28
|
+
checklist: ["執行 lint", "執行 lint:check", "修正 max-warnings 問題"],
|
|
29
|
+
},
|
|
30
|
+
test: {
|
|
31
|
+
name: "treg/test",
|
|
32
|
+
description: "Validate test runner setup and execution.",
|
|
33
|
+
when: "新增測試規則或調整測試設定時。",
|
|
34
|
+
checklist: [
|
|
35
|
+
"確認 test runner 與專案一致",
|
|
36
|
+
"執行 test",
|
|
37
|
+
"視需要執行 test:coverage",
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
typescript: {
|
|
41
|
+
name: "treg/typescript",
|
|
42
|
+
description: "Validate TypeScript strictness and config.",
|
|
43
|
+
when: "調整 tsconfig 或型別嚴格度規則時。",
|
|
44
|
+
checklist: [
|
|
45
|
+
"執行 type-check",
|
|
46
|
+
"確認 strict 相關選項仍生效",
|
|
47
|
+
"檢查 exclude 不含產品邏輯路徑",
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const FEATURE_STEP_LABELS = {
|
|
52
|
+
format: "格式處理",
|
|
53
|
+
husky: "Git hook 維護",
|
|
54
|
+
lint: "Lint 規則檢查",
|
|
55
|
+
test: "測試規則調整",
|
|
56
|
+
typescript: "TypeScript 型別與設定",
|
|
57
|
+
};
|
|
58
|
+
function resolveSkillsDoc(projectDir) {
|
|
59
|
+
const agentsPath = path.join(projectDir, "AGENTS.md");
|
|
60
|
+
if (existsSync(agentsPath)) {
|
|
61
|
+
return agentsPath;
|
|
62
|
+
}
|
|
63
|
+
const claudePath = path.join(projectDir, "CLAUDE.md");
|
|
64
|
+
if (existsSync(claudePath)) {
|
|
65
|
+
return claudePath;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
function getEnabledFeatures(enabledFeatures) {
|
|
70
|
+
return Object.entries(enabledFeatures)
|
|
71
|
+
.filter(([, value]) => value)
|
|
72
|
+
.map(([name]) => name)
|
|
73
|
+
.sort((a, b) => a.localeCompare(b));
|
|
74
|
+
}
|
|
75
|
+
function getSkillRelativePath(feature) {
|
|
76
|
+
return `${SKILLS_BASE_DIR}/${feature}/SKILL.md`;
|
|
77
|
+
}
|
|
78
|
+
function buildSkillFile(feature, skill, testRunner) {
|
|
79
|
+
const extra = feature === "test"
|
|
80
|
+
? `\n## Current Test Runner\n\n- \`${testRunner}\`\n`
|
|
81
|
+
: "";
|
|
82
|
+
return `---
|
|
83
|
+
name: ${skill.name}
|
|
84
|
+
description: ${skill.description}
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
# ${skill.name}
|
|
88
|
+
|
|
89
|
+
## When To Use
|
|
90
|
+
|
|
91
|
+
${skill.when}
|
|
92
|
+
|
|
93
|
+
## Validation Checklist
|
|
94
|
+
|
|
95
|
+
- ${skill.checklist.join("\n- ")}
|
|
96
|
+
${extra}`;
|
|
97
|
+
}
|
|
98
|
+
async function ensureSkillFiles(projectDir, enabled, testRunner, dryRun) {
|
|
99
|
+
for (const feature of enabled) {
|
|
100
|
+
const skill = FEATURE_SKILLS[feature];
|
|
101
|
+
if (!skill)
|
|
102
|
+
continue;
|
|
103
|
+
const relativePath = getSkillRelativePath(feature);
|
|
104
|
+
const fullPath = path.join(projectDir, relativePath);
|
|
105
|
+
const content = buildSkillFile(feature, skill, testRunner);
|
|
106
|
+
if (dryRun) {
|
|
107
|
+
console.log(`[dry-run] Would upsert ${relativePath}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
111
|
+
const current = existsSync(fullPath)
|
|
112
|
+
? await fs.readFile(fullPath, "utf8")
|
|
113
|
+
: null;
|
|
114
|
+
if (current === content) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
118
|
+
console.log(`${current === null ? "Created" : "Updated"} ${relativePath}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function buildSkillSection(context) {
|
|
122
|
+
const { enabledFeatures, testRunner } = context;
|
|
123
|
+
const enabled = getEnabledFeatures(enabledFeatures);
|
|
124
|
+
const lines = [
|
|
125
|
+
START_MARKER,
|
|
126
|
+
"## treg AI Skills",
|
|
127
|
+
"",
|
|
128
|
+
"### 執行步驟與 Skill 對應",
|
|
129
|
+
"",
|
|
130
|
+
];
|
|
131
|
+
if (enabled.length === 0) {
|
|
132
|
+
lines.push("1. 本次未啟用任何 feature,無需呼叫 skill。");
|
|
133
|
+
lines.push("");
|
|
134
|
+
lines.push(END_MARKER);
|
|
135
|
+
lines.push("");
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
enabled.forEach((feature, index) => {
|
|
139
|
+
const skill = FEATURE_SKILLS[feature];
|
|
140
|
+
if (!skill)
|
|
141
|
+
return;
|
|
142
|
+
const skillRelativePath = getSkillRelativePath(feature);
|
|
143
|
+
const stepLabel = FEATURE_STEP_LABELS[feature] ?? feature;
|
|
144
|
+
lines.push(`${index + 1}. ${stepLabel}:呼叫 [${skill.name}](${skillRelativePath})`);
|
|
145
|
+
if (feature === "test") {
|
|
146
|
+
lines.push(` - 目前測試工具:\`${testRunner}\``);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push(END_MARKER);
|
|
151
|
+
lines.push("");
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
function upsertSkillSection(content, nextSection) {
|
|
155
|
+
const start = content.indexOf(START_MARKER);
|
|
156
|
+
const end = content.indexOf(END_MARKER);
|
|
157
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
158
|
+
const suffixStart = end + END_MARKER.length;
|
|
159
|
+
const before = content.slice(0, start).trimEnd();
|
|
160
|
+
const after = content.slice(suffixStart).trimStart();
|
|
161
|
+
const rebuilt = `${before}\n\n${nextSection.trim()}\n`;
|
|
162
|
+
return after ? `${rebuilt}\n${after}\n` : `${rebuilt}`;
|
|
163
|
+
}
|
|
164
|
+
if (!content.trim()) {
|
|
165
|
+
return `${nextSection.trim()}\n`;
|
|
166
|
+
}
|
|
167
|
+
return `${content.trimEnd()}\n\n${nextSection.trim()}\n`;
|
|
168
|
+
}
|
|
169
|
+
export async function runAiSkillsRule(context) {
|
|
170
|
+
const { projectDir, dryRun } = context;
|
|
171
|
+
const targetFile = resolveSkillsDoc(projectDir);
|
|
172
|
+
if (!targetFile) {
|
|
173
|
+
console.log("Skip ai-skills (AGENTS.md/CLAUDE.md not found)");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const enabled = getEnabledFeatures(context.enabledFeatures);
|
|
177
|
+
await ensureSkillFiles(projectDir, enabled, context.testRunner, dryRun);
|
|
178
|
+
const section = buildSkillSection(context);
|
|
179
|
+
const current = await fs.readFile(targetFile, "utf8");
|
|
180
|
+
const updated = upsertSkillSection(current, section);
|
|
181
|
+
if (dryRun) {
|
|
182
|
+
console.log(`[dry-run] Would update ${path.basename(targetFile)} with AI skill guidance`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (updated !== current) {
|
|
186
|
+
await fs.writeFile(targetFile, updated, "utf8");
|
|
187
|
+
console.log(`Updated ${path.basename(targetFile)} with AI skill guidance`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
console.log(`${path.basename(targetFile)} already contains latest AI skill guidance`);
|
|
191
|
+
}
|
|
192
|
+
export const __testables__ = {
|
|
193
|
+
buildSkillSection,
|
|
194
|
+
buildSkillFile,
|
|
195
|
+
ensureSkillFiles,
|
|
196
|
+
getEnabledFeatures,
|
|
197
|
+
getSkillRelativePath,
|
|
198
|
+
resolveSkillsDoc,
|
|
199
|
+
upsertSkillSection,
|
|
200
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { lines, packageJson } from "../mrm-core.js";
|
|
2
|
+
import { installPackages, withProjectCwd, writeFile } from "./shared.js";
|
|
3
|
+
const PRETTIER_CONFIG = `{
|
|
4
|
+
"semi": false,
|
|
5
|
+
"trailingComma": "es5",
|
|
6
|
+
"singleQuote": false,
|
|
7
|
+
"printWidth": 80,
|
|
8
|
+
"tabWidth": 2,
|
|
9
|
+
"useTabs": false,
|
|
10
|
+
"bracketSpacing": true,
|
|
11
|
+
"arrowParens": "avoid",
|
|
12
|
+
"endOfLine": "lf"
|
|
13
|
+
}
|
|
14
|
+
`;
|
|
15
|
+
const PRETTIER_IGNORE = [
|
|
16
|
+
"node_modules",
|
|
17
|
+
".git",
|
|
18
|
+
"dist",
|
|
19
|
+
"build",
|
|
20
|
+
"coverage",
|
|
21
|
+
"*.log",
|
|
22
|
+
".env*",
|
|
23
|
+
".vercel",
|
|
24
|
+
"pnpm-lock.yaml",
|
|
25
|
+
"package-lock.json",
|
|
26
|
+
"yarn.lock",
|
|
27
|
+
];
|
|
28
|
+
export async function runFormatRule(context) {
|
|
29
|
+
const { projectDir, pm, force, dryRun } = context;
|
|
30
|
+
installPackages(projectDir, pm, ["prettier"], true, dryRun);
|
|
31
|
+
await writeFile(projectDir, ".prettierrc.json", PRETTIER_CONFIG, force, dryRun);
|
|
32
|
+
withProjectCwd(projectDir, () => {
|
|
33
|
+
if (dryRun) {
|
|
34
|
+
console.log("[dry-run] Would update .prettierignore");
|
|
35
|
+
console.log("[dry-run] Would set package scripts: format, format:check");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
lines(".prettierignore").add(PRETTIER_IGNORE).save();
|
|
39
|
+
packageJson()
|
|
40
|
+
.setScript("format", "prettier --write .")
|
|
41
|
+
.setScript("format:check", "prettier --check .")
|
|
42
|
+
.save();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { packageJson } from "../mrm-core.js";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { getRunCommand, runCommand } from "../package-manager.js";
|
|
6
|
+
import { installPackages, withProjectCwd, writeFile } from "./shared.js";
|
|
7
|
+
function buildHookCommands(runner, enabledFeatures) {
|
|
8
|
+
const preCommit = [
|
|
9
|
+
`${runner} format:check || exit 1`,
|
|
10
|
+
`${runner} lint:check || exit 1`,
|
|
11
|
+
];
|
|
12
|
+
if (enabledFeatures.typescript) {
|
|
13
|
+
preCommit.push(`${runner} type-check || exit 1`);
|
|
14
|
+
}
|
|
15
|
+
const prePush = [...preCommit];
|
|
16
|
+
if (enabledFeatures.test) {
|
|
17
|
+
prePush.push(`${runner} test || exit 1`);
|
|
18
|
+
}
|
|
19
|
+
return { preCommit, prePush };
|
|
20
|
+
}
|
|
21
|
+
export async function runHuskyRule(context) {
|
|
22
|
+
const { projectDir, pm, force, dryRun, skipHuskyInstall, enabledFeatures } = context;
|
|
23
|
+
installPackages(projectDir, pm, ["husky"], true, dryRun);
|
|
24
|
+
const runner = getRunCommand(pm);
|
|
25
|
+
const { preCommit, prePush } = buildHookCommands(runner, enabledFeatures);
|
|
26
|
+
await writeFile(projectDir, ".husky/pre-commit", `# Husky pre-commit\n${preCommit.join("\n")}\n`, force, dryRun);
|
|
27
|
+
await writeFile(projectDir, ".husky/pre-push", `# Husky pre-push\n${prePush.join("\n")}\n`, force, dryRun);
|
|
28
|
+
if (!dryRun) {
|
|
29
|
+
await fs.chmod(path.join(projectDir, ".husky/pre-commit"), 0o755);
|
|
30
|
+
await fs.chmod(path.join(projectDir, ".husky/pre-push"), 0o755);
|
|
31
|
+
}
|
|
32
|
+
withProjectCwd(projectDir, () => {
|
|
33
|
+
if (dryRun) {
|
|
34
|
+
console.log("[dry-run] Would set package script: prepare");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
packageJson().setScript("prepare", "husky").save();
|
|
38
|
+
});
|
|
39
|
+
if (skipHuskyInstall) {
|
|
40
|
+
console.log("Skip husky install (--skip-husky-install)");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!existsSync(path.join(projectDir, ".git"))) {
|
|
44
|
+
console.log("Skip husky install (.git not found)");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!dryRun) {
|
|
48
|
+
runHuskyInstallCommand(pm, projectDir);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function runHuskyInstallCommand(pm, projectDir) {
|
|
52
|
+
if (pm === "pnpm") {
|
|
53
|
+
runCommand("pnpm exec husky", projectDir, false);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (pm === "yarn") {
|
|
57
|
+
runCommand("yarn husky", projectDir, false);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
runCommand("npx husky", projectDir, false);
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { runAiSkillsRule } from "./ai-skills.js";
|
|
2
|
+
import { runFormatRule } from "./format.js";
|
|
3
|
+
import { runHuskyRule } from "./husky.js";
|
|
4
|
+
import { runLintRule } from "./lint.js";
|
|
5
|
+
import { runTestJestRule } from "./test-jest.js";
|
|
6
|
+
import { runTestVitestRule } from "./test-vitest.js";
|
|
7
|
+
import { runTypescriptRule } from "./typescript.js";
|
|
8
|
+
export async function runFeatureRules(context) {
|
|
9
|
+
const { enabledFeatures, skills, testRunner } = context;
|
|
10
|
+
if (enabledFeatures.format) {
|
|
11
|
+
await runFormatRule(context);
|
|
12
|
+
}
|
|
13
|
+
if (enabledFeatures.lint) {
|
|
14
|
+
await runLintRule(context);
|
|
15
|
+
}
|
|
16
|
+
if (enabledFeatures.typescript) {
|
|
17
|
+
await runTypescriptRule(context);
|
|
18
|
+
}
|
|
19
|
+
if (enabledFeatures.test) {
|
|
20
|
+
if (testRunner === "vitest") {
|
|
21
|
+
await runTestVitestRule(context);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
await runTestJestRule(context);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (enabledFeatures.husky) {
|
|
28
|
+
await runHuskyRule(context);
|
|
29
|
+
}
|
|
30
|
+
if (skills) {
|
|
31
|
+
await runAiSkillsRule(context);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { packageJson } from "../mrm-core.js";
|
|
2
|
+
import { installPackages, withProjectCwd } from "./shared.js";
|
|
3
|
+
export async function runLintRule(context) {
|
|
4
|
+
const { projectDir, pm, dryRun } = context;
|
|
5
|
+
installPackages(projectDir, pm, ["eslint"], true, dryRun);
|
|
6
|
+
withProjectCwd(projectDir, () => {
|
|
7
|
+
if (dryRun) {
|
|
8
|
+
console.log("[dry-run] Would set package scripts: lint, lint:check");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
packageJson()
|
|
12
|
+
.setScript("lint", "eslint .")
|
|
13
|
+
.setScript("lint:check", "eslint . --max-warnings 0")
|
|
14
|
+
.save();
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { installPackages as installByPackageManager } from "../package-manager.js";
|
|
6
|
+
export function withProjectCwd(projectDir, fn) {
|
|
7
|
+
const original = process.cwd();
|
|
8
|
+
process.chdir(projectDir);
|
|
9
|
+
try {
|
|
10
|
+
return fn();
|
|
11
|
+
}
|
|
12
|
+
finally {
|
|
13
|
+
process.chdir(original);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function getInstallOptions(pm) {
|
|
17
|
+
if (pm === "pnpm")
|
|
18
|
+
return { pnpm: true };
|
|
19
|
+
if (pm === "yarn")
|
|
20
|
+
return { yarn: true };
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
export function installPackages(projectDir, pm, packages, dev = true, dryRun = false) {
|
|
24
|
+
const missingPackages = filterUninstalledPackages(projectDir, packages);
|
|
25
|
+
if (missingPackages.length === 0) {
|
|
26
|
+
console.log(`Skip install (already present): ${packages.join(", ")}`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (missingPackages.length !== packages.length) {
|
|
30
|
+
const installedPackages = packages.filter(candidate => !missingPackages.includes(candidate));
|
|
31
|
+
console.log(`Skip already installed: ${installedPackages.join(", ")}`);
|
|
32
|
+
}
|
|
33
|
+
installByPackageManager(pm, projectDir, missingPackages, dev, dryRun);
|
|
34
|
+
}
|
|
35
|
+
export function filterUninstalledPackages(projectDir, packages) {
|
|
36
|
+
if (packages.length === 0)
|
|
37
|
+
return [];
|
|
38
|
+
const installed = getInstalledPackageNames(projectDir);
|
|
39
|
+
if (installed.size === 0)
|
|
40
|
+
return packages;
|
|
41
|
+
return packages.filter(pkg => !installed.has(getPackageName(pkg)));
|
|
42
|
+
}
|
|
43
|
+
function getInstalledPackageNames(projectDir) {
|
|
44
|
+
const packageJsonPath = path.join(projectDir, "package.json");
|
|
45
|
+
if (!existsSync(packageJsonPath))
|
|
46
|
+
return new Set();
|
|
47
|
+
try {
|
|
48
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
49
|
+
return new Set([
|
|
50
|
+
...Object.keys(packageJson.dependencies ?? {}),
|
|
51
|
+
...Object.keys(packageJson.devDependencies ?? {}),
|
|
52
|
+
...Object.keys(packageJson.peerDependencies ?? {}),
|
|
53
|
+
...Object.keys(packageJson.optionalDependencies ?? {}),
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return new Set();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function getPackageName(pkg) {
|
|
61
|
+
if (pkg.startsWith("@")) {
|
|
62
|
+
const scopeEnd = pkg.indexOf("/", 1);
|
|
63
|
+
if (scopeEnd < 0)
|
|
64
|
+
return pkg;
|
|
65
|
+
const versionStart = pkg.indexOf("@", scopeEnd + 1);
|
|
66
|
+
return versionStart < 0 ? pkg : pkg.slice(0, versionStart);
|
|
67
|
+
}
|
|
68
|
+
const versionStart = pkg.indexOf("@");
|
|
69
|
+
return versionStart < 0 ? pkg : pkg.slice(0, versionStart);
|
|
70
|
+
}
|
|
71
|
+
export async function writeFile(projectDir, relativePath, content, force, dryRun) {
|
|
72
|
+
const targetPath = path.join(projectDir, relativePath);
|
|
73
|
+
try {
|
|
74
|
+
await fs.access(targetPath);
|
|
75
|
+
if (!force) {
|
|
76
|
+
console.log(`Skip ${relativePath} (already exists)`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// File doesn't exist.
|
|
82
|
+
}
|
|
83
|
+
if (dryRun) {
|
|
84
|
+
console.log(`[dry-run] Would ${force ? "update" : "create"} ${relativePath}`);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
88
|
+
await fs.writeFile(targetPath, content, "utf8");
|
|
89
|
+
console.log(`${force ? "Updated" : "Created"} ${relativePath}`);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { packageJson } from "../mrm-core.js";
|
|
2
|
+
import { installPackages, withProjectCwd, writeFile } from "./shared.js";
|
|
3
|
+
function getJestConfig(testEnvironment) {
|
|
4
|
+
return `/** @type {import("jest").Config} */
|
|
5
|
+
const config = {
|
|
6
|
+
testEnvironment: "${testEnvironment}",
|
|
7
|
+
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
|
|
8
|
+
testMatch: ["**/*.test.[jt]s?(x)", "**/*.test.ts"],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = config
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
14
|
+
function getSetupFile(frameworkId) {
|
|
15
|
+
if (frameworkId === "react") {
|
|
16
|
+
return `require("@testing-library/jest-dom")
|
|
17
|
+
|
|
18
|
+
// Jest setup (add custom matchers or globals here)
|
|
19
|
+
`;
|
|
20
|
+
}
|
|
21
|
+
return `// Jest setup (add custom matchers or globals here)
|
|
22
|
+
`;
|
|
23
|
+
}
|
|
24
|
+
export async function runTestJestRule(context) {
|
|
25
|
+
const { framework, projectDir, pm, force, dryRun } = context;
|
|
26
|
+
const deps = ["jest"];
|
|
27
|
+
if (framework.testEnvironment === "jsdom") {
|
|
28
|
+
deps.push("jest-environment-jsdom");
|
|
29
|
+
}
|
|
30
|
+
if (framework.id === "react") {
|
|
31
|
+
deps.push("@testing-library/jest-dom");
|
|
32
|
+
deps.push("@testing-library/react");
|
|
33
|
+
}
|
|
34
|
+
installPackages(projectDir, pm, deps, true, dryRun);
|
|
35
|
+
await writeFile(projectDir, "jest.config.js", getJestConfig(framework.testEnvironment), force, dryRun);
|
|
36
|
+
await writeFile(projectDir, "jest.setup.js", getSetupFile(framework.id), force, dryRun);
|
|
37
|
+
withProjectCwd(projectDir, () => {
|
|
38
|
+
if (dryRun) {
|
|
39
|
+
console.log("[dry-run] Would set package scripts: test, test:watch, test:coverage");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
packageJson()
|
|
43
|
+
.setScript("test", "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests")
|
|
44
|
+
.setScript("test:watch", "NODE_OPTIONS=--experimental-vm-modules jest --watch")
|
|
45
|
+
.setScript("test:coverage", "NODE_OPTIONS=--experimental-vm-modules jest --coverage")
|
|
46
|
+
.save();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { packageJson } from "../mrm-core.js";
|
|
2
|
+
import { installPackages, withProjectCwd, writeFile } from "./shared.js";
|
|
3
|
+
function getVitestConfig(framework) {
|
|
4
|
+
return `import { defineConfig } from "vitest/config"
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
test: {
|
|
8
|
+
environment: "${framework.testEnvironment}",
|
|
9
|
+
setupFiles: ["./vitest.setup.js"],
|
|
10
|
+
},
|
|
11
|
+
})
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
14
|
+
function getVitestSetup(framework) {
|
|
15
|
+
if (framework.id === "react") {
|
|
16
|
+
return `import "@testing-library/jest-dom/vitest"
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
19
|
+
return `// Vitest setup
|
|
20
|
+
`;
|
|
21
|
+
}
|
|
22
|
+
export async function runTestVitestRule(context) {
|
|
23
|
+
const { framework, projectDir, pm, force, dryRun } = context;
|
|
24
|
+
const deps = ["vitest"];
|
|
25
|
+
if (framework.testEnvironment === "jsdom") {
|
|
26
|
+
deps.push("jsdom");
|
|
27
|
+
}
|
|
28
|
+
if (framework.id === "react") {
|
|
29
|
+
deps.push("@testing-library/jest-dom");
|
|
30
|
+
deps.push("@testing-library/react");
|
|
31
|
+
}
|
|
32
|
+
installPackages(projectDir, pm, deps, true, dryRun);
|
|
33
|
+
await writeFile(projectDir, "vitest.config.ts", getVitestConfig(framework), force, dryRun);
|
|
34
|
+
await writeFile(projectDir, "vitest.setup.js", getVitestSetup(framework), force, dryRun);
|
|
35
|
+
withProjectCwd(projectDir, () => {
|
|
36
|
+
if (dryRun) {
|
|
37
|
+
console.log("[dry-run] Would set package scripts: test, test:watch, test:coverage");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
packageJson()
|
|
41
|
+
.setScript("test", "vitest run")
|
|
42
|
+
.setScript("test:watch", "vitest")
|
|
43
|
+
.setScript("test:coverage", "vitest run --coverage")
|
|
44
|
+
.save();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { json, packageJson } from "../mrm-core.js";
|
|
2
|
+
import { installPackages, withProjectCwd } from "./shared.js";
|
|
3
|
+
const TS_REQUIRED_OPTIONS = {
|
|
4
|
+
strict: true,
|
|
5
|
+
strictNullChecks: true,
|
|
6
|
+
noImplicitAny: true,
|
|
7
|
+
noImplicitThis: true,
|
|
8
|
+
exactOptionalPropertyTypes: true,
|
|
9
|
+
noUncheckedIndexedAccess: true,
|
|
10
|
+
noUnusedLocals: true,
|
|
11
|
+
noUnusedParameters: true,
|
|
12
|
+
};
|
|
13
|
+
export async function runTypescriptRule(context) {
|
|
14
|
+
const { framework, projectDir, pm, dryRun } = context;
|
|
15
|
+
installPackages(projectDir, pm, ["typescript"], true, dryRun);
|
|
16
|
+
withProjectCwd(projectDir, () => {
|
|
17
|
+
if (dryRun) {
|
|
18
|
+
console.log("[dry-run] Would update tsconfig.json");
|
|
19
|
+
console.log("[dry-run] Would set package script: type-check");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const tsconfig = json("tsconfig.json", {
|
|
23
|
+
compilerOptions: {},
|
|
24
|
+
exclude: [],
|
|
25
|
+
});
|
|
26
|
+
const mergedCompilerOptions = {
|
|
27
|
+
...(tsconfig.get("compilerOptions") ?? {}),
|
|
28
|
+
...TS_REQUIRED_OPTIONS,
|
|
29
|
+
};
|
|
30
|
+
const exclude = new Set(tsconfig.get("exclude", []));
|
|
31
|
+
for (const entry of framework.tsRequiredExcludes) {
|
|
32
|
+
exclude.add(entry);
|
|
33
|
+
}
|
|
34
|
+
tsconfig
|
|
35
|
+
.set("compilerOptions", mergedCompilerOptions)
|
|
36
|
+
.set("exclude", Array.from(exclude))
|
|
37
|
+
.save();
|
|
38
|
+
packageJson().setScript("type-check", "tsc --noEmit").save();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function detectPackageManager(projectDir) {
|
|
5
|
+
if (existsSync(path.join(projectDir, "pnpm-lock.yaml"))) {
|
|
6
|
+
return "pnpm";
|
|
7
|
+
}
|
|
8
|
+
if (existsSync(path.join(projectDir, "yarn.lock"))) {
|
|
9
|
+
return "yarn";
|
|
10
|
+
}
|
|
11
|
+
if (existsSync(path.join(projectDir, "package-lock.json"))) {
|
|
12
|
+
return "npm";
|
|
13
|
+
}
|
|
14
|
+
return "npm";
|
|
15
|
+
}
|
|
16
|
+
export function getRunCommand(pm) {
|
|
17
|
+
if (pm === "pnpm")
|
|
18
|
+
return "pnpm";
|
|
19
|
+
if (pm === "yarn")
|
|
20
|
+
return "yarn";
|
|
21
|
+
return "npm run";
|
|
22
|
+
}
|
|
23
|
+
export function runCommand(command, cwd, dryRun = false) {
|
|
24
|
+
if (dryRun) {
|
|
25
|
+
console.log(`[dry-run] Would run: ${command}`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
execSync(command, { cwd, stdio: "inherit" });
|
|
29
|
+
}
|
|
30
|
+
export function runScript(pm, scriptName, cwd, dryRun = false) {
|
|
31
|
+
if (pm === "pnpm") {
|
|
32
|
+
runCommand(`pnpm ${scriptName}`, cwd, dryRun);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (pm === "yarn") {
|
|
36
|
+
runCommand(`yarn ${scriptName}`, cwd, dryRun);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
runCommand(`npm run ${scriptName}`, cwd, dryRun);
|
|
40
|
+
}
|
|
41
|
+
export function installPackages(pm, projectDir, packages, isDev, dryRun = false) {
|
|
42
|
+
if (packages.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
const list = packages.join(" ");
|
|
45
|
+
let command = "";
|
|
46
|
+
if (pm === "pnpm") {
|
|
47
|
+
command = `pnpm add ${isDev ? "-D " : ""}${list}`;
|
|
48
|
+
}
|
|
49
|
+
else if (pm === "yarn") {
|
|
50
|
+
command = `yarn add ${isDev ? "-D " : ""}${list}`;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
command = `npm install ${isDev ? "-D " : ""}${list}`;
|
|
54
|
+
}
|
|
55
|
+
console.log(`${dryRun ? "[dry-run] " : ""}Installing ${isDev ? "dev " : ""}dependencies: ${packages.join(", ")}`);
|
|
56
|
+
runCommand(command, projectDir, dryRun);
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function hasPackage(pkg, name) {
|
|
2
|
+
return Boolean(pkg.dependencies?.[name] ||
|
|
3
|
+
pkg.devDependencies?.[name] ||
|
|
4
|
+
pkg.peerDependencies?.[name]);
|
|
5
|
+
}
|
|
6
|
+
export function formatStep(step, total, message, dryRun) {
|
|
7
|
+
const suffix = dryRun ? " [dry-run]" : "";
|
|
8
|
+
return `[${step}/${total}] ${message}${suffix}`;
|
|
9
|
+
}
|